Merge main into go-int/work

This commit is contained in:
Doxa Maps 2026-09-27 03:46:15 +00:00
commit cfae5f1adb
143 changed files with 3375 additions and 1406 deletions

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,7 @@
"meta": {
"title": "Messaging & App Channels",
"grain": "one row per country x communication app",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp is the top messaging app in 54 countries, Telegram in 38 — neither appears in referral share at all.",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp (Messenger or Business) is the top messaging app in 73 countries, Telegram in 38 — neither appears in referral share at all.",
"ios_caveat": "iOS ONLY. Most of these countries are Android-majority and Google publishes no keyless equivalent, so this is one eye open. An app absent here is NOT absent in-country. 50 of 175 countries have no Apple storefront.",
"ranking_caveat": "A top-free RANK is most-DOWNLOADED recently, not most-used.",
"classification_caveat": "kind is a name-keyword HEURISTIC over a known list, not a verified taxonomy.",
@ -2830,19 +2830,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Thailand",
"iso3": "THA",
"region": "Asia",
"app": "LINE MAN: Food Delivery & more",
"kind": "messaging",
"rank": 13,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Burkina Faso",
"iso3": "BFA",
@ -3006,7 +2993,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 37,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
@ -7809,19 +7796,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Maldives",
"iso3": "MDV",
"region": "Asia",
"app": "Malé Taxi Line",
"kind": "messaging",
"rank": 1,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Northern Mariana Islands",
"iso3": "MNP",
@ -13120,7 +13094,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 33,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",

View file

@ -84,6 +84,8 @@ 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'`
- **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'`
- **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).

View file

@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* check-i18n-stopped-scope.mjs — createAppI18n() must succeed while a STOPPED effect scope is current.
*
* WHY (P0, the Driver's console, 2026-09-27): in Plugin Studio's gallery every tab after the first threw
* "SyntaxError: Unexpected error" from createI18n inside createAppI18n. vue-i18n's createGlobal() runs in
* scope.run() and throws UNEXPECTED_ERROR when that returns undefined; Vue 3.5 creates a new EffectScope
* INACTIVE when the currently active one is stopped; and a custom element's connectedCallback ->
* configureApp -> createAppI18n can run while the released tab's stopped scope is still current.
*
* The CONTROL comes first: a bare createI18n() inside the same stopped scope must THROW, or this harness
* does not reproduce the failure and a green result would prove nothing. Then createAppI18n() inside it
* must succeed, translate, and keep reacting to a locale change.
*/
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
let bad = 0
const check = (name, ok, got) => { if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ' — ' + got}`) }
const { createServer } = await import('vite')
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
server: { middlewareMode: true, hmr: false, ws: false, watch: null }, resolve: { alias: { '@map': join(ROOT, 'library') } } })
try {
globalThis.__INTLIFY_JIT_COMPILATION__ = true
const { effectScope } = await import('vue')
const { createI18n } = await import('vue-i18n')
const mod = await server.ssrLoadModule('/library/i18n/index.js')
// Set AFTER vue loads (runtime-dom probes `document` at import). These are the two page facts
// createAppI18n reads, the URL and <html lang>, as a plain English page would give them.
globalThis.window ??= { location: { search: '' } }
globalThis.document ??= { documentElement: { lang: 'en', dir: '' } }
// A scope that ran, was stopped, and is still the current one — the released tab's.
const stopped = effectScope()
stopped.run(() => {})
stopped.stop()
const inStopped = (fn) => { stopped.on(); try { return fn() } finally { stopped.off() } }
let controlThrew = false
try { inStopped(() => createI18n({ legacy: false, locale: 'en', messages: { en: { a: 'b' } } })) } catch { controlThrew = true }
check('CONTROL: a bare createI18n() inside a stopped scope throws (the harness reproduces the bug)', controlThrew, 'it did not throw')
let i18n = null, err = null
try { i18n = inStopped(() => mod.createAppI18n()) } catch (e) { err = e }
check('createAppI18n() succeeds inside a stopped scope', !!i18n && !err, err && String(err))
if (i18n) {
check('and it translates', i18n.global.t('buttons.close') !== 'buttons.close', i18n.global.t('buttons.close'))
i18n.global.locale.value = 'ar'
await new Promise((r) => setTimeout(r, 0))
check('and its locale watcher is live (the static reader follows a runtime switch)', mod.getRuntimeLocale() === 'ar', mod.getRuntimeLocale())
// The bundle entries that still provide appI18n themselves after app.use(i18n) must not make Vue warn.
const { createApp } = await import('vue')
const warns = []; const warn = console.warn; console.warn = (...a) => warns.push(a.join(' '))
try { const app = createApp({}); app.use(i18n); app.provide('appI18n', i18n) } finally { console.warn = warn }
check('a bundle providing appI18n again after app.use(i18n) raises no Vue warning', !warns.some((w) => /appI18n/.test(w)), warns.join(' | '))
}
} finally {
await server.close()
}
if (bad) { console.error(`\nI18N-STOPPED-SCOPE: FAIL — ${bad} assertion(s)`); process.exit(1) }
console.log('\nI18N-STOPPED-SCOPE: PASS — the app i18n is created live even while a stopped effect scope is current.')

View file

@ -63,12 +63,36 @@ const PATTERN = new RegExp(
function scan(text) {
const hits = []
const lines = text.split('\n')
// ⚠ BLOCK-COMMENT STATE, not a per-line prefix test. The first version asked only whether a line
// STARTS with //, * or /*, and that is not what a comment is: it misses every CONTINUATION line of a
// /* … */ block whose text happens to begin with something else.
//
// IT COST A FALSE ACCUSATION, 2026-09-27, and the accusation was aimed at the correct fix. gospel
// fixed this defect in resources-map.vue — `const gl = engineGlobal(grProvider)`, no `||` — and
// documented what they removed in a /* */ block whose second line begins with a BACKTICK:
// `engineGlobal(grProvider) || window.mapboxgl`. engineGlobal already defaults to mapbox, so
// The prefix test saw no comment marker, matched the quoted pattern, and reported gospel as the last
// remaining offender at main 9df086e5. I relayed that to dx-shepherd as fact and it was wrong: the
// line number had also moved three times (1490 -> 1505 -> 1509) purely because their file grew, which
// is what made a stale card look like a live defect.
// ★ A GATE THAT FAILS ON THE EXPLANATION OF A FIX IS WORSE THAN NO GATE. It tells the one person who
// did the right thing that they did the wrong thing, and the cheapest way to make it green is to
// DELETE the explanation — so the gate actively destroys the reasoning it should protect. This file's
// own header quotes the pattern for the same reason, which is why the skip existed at all; it was
// just too shallow to cover the form another agent actually used.
let inBlock = false
lines.forEach((line, i) => {
// Skip comment lines: this file's own documentation quotes the bad pattern, and so does every fix
// that explains what it removed. A gate that fails on its own explanation teaches people to delete
// the explanation.
const t = line.trim()
if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return
const wasInBlock = inBlock
// Track /* and */ on this line so the NEXT line knows where it stands. Deliberately simple: a
// block marker inside a string literal would fool it, and the alternative is a JS parser for a
// one-expression grep. The failure mode of over-skipping is a missed offender, which the
// --self-test guards against by keeping real forms in the MUST-MATCH list.
const opens = (line.match(/\/\*/g) || []).length
const closes = (line.match(/\*\//g) || []).length
if (opens > closes) inBlock = true
else if (closes > opens) inBlock = false
if (wasInBlock || t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return
PATTERN.lastIndex = 0
let m
while ((m = PATTERN.exec(line)) !== null) hits.push({ line: i + 1, engine: m[1], text: t.slice(0, 110) })
@ -90,6 +114,21 @@ if (SELF_TEST) {
'const gl = engineGlobal(p) || null',
'const token = mapboxToken?.value || profileConfig?.value?.tk || \'\'',
]
// ★ MULTI-LINE CASES, because the single-line list above cannot express the bug that actually
// happened: a CONTINUATION line of a /* */ block. Each entry is scanned as one text so the
// block-comment state machine is what is under test, not the prefix check.
const shouldNotMultiline = [
// gospel's real fix at resources-map.vue, verbatim in shape — the line that was falsely accused
// begins with a BACKTICK, so no prefix test could ever have skipped it.
[` /* ⚠ NEVER SWITCH ENGINES SILENTLY. This used to read
\`engineGlobal(grProvider) || window.mapboxgl\`. engineGlobal already defaults to mapbox, so
the fallback could only ever fire for provider 'maplibre'. */
const gl = engineGlobal(grProvider)`, 'a /* */ block quoting the removed fallback, then the correct fix'],
// and the inverse: a real offender AFTER a block comment closes must still be caught, or the fix
// above would have traded a false positive for a false negative.
[` /* explanation that mentions engineGlobal(p) || window.mapboxgl and then ends */
const gl = engineGlobal(p) || window.mapboxgl`, null],
]
let bad = 0
for (const s of should) {
const n = scan(s).length
@ -101,6 +140,17 @@ if (SELF_TEST) {
console.log(` ${n ? 'FALSE+ ' : 'ignored '} ${s.trim().slice(0, 62)}`)
if (n) bad++
}
// The multi-line pairs: a label means "must be IGNORED and here is why", null means "must MATCH".
// Both directions are asserted together on purpose — fixing the false positive by skipping more is
// only correct if a real offender after the block still fails.
for (const [text, why] of shouldNotMultiline) {
const n = scan(text).length
const wantHit = why === null
const ok = wantHit ? n > 0 : n === 0
if (!ok) bad++
console.log(` ${ok ? (wantHit ? 'MATCHED ' : 'ignored ') : (wantHit ? 'MISSED ' : 'FALSE+ ')} `
+ `[multi-line] ${wantHit ? 'a real offender AFTER a closed block comment' : why}`)
}
if (bad) {
console.error(`\nNO-SILENT-ENGINE-SUBSTITUTION --self-test: FAIL — ${bad} case(s) wrong.`)
process.exit(1)

View file

@ -1519,6 +1519,12 @@ function main() {
// for maps that colour every state. The curated file above is UPG-100's 34-unit partition.
[resolve(__dirname, '..', 'data', 'boundaries', 'gb-IND-ADM1-full.geojson'), 'gb-IND-ADM1-full.geojson'],
[resolve(__dirname, '..', 'data', 'boundaries', 'gb-IND-ADM1-full.meta.json'), 'gb-IND-ADM1-full.meta.json'],
// WEB copies (library/scripts/boundaries/build-web-boundaries.mjs): every feature and island kept, about
// a fifth of the bytes. The interactive maps fetch these; posters, print and build-time point-in-polygon
// keep the full files above (REPORT-WEB-BOUNDARIES.md).
[join(dataDir, 'adm0', 'world-adm0-web.geojson'), 'world-adm0-web.geojson'],
[join(dataDir, 'gb-IND-ADM1-web.geojson'), 'gb-IND-ADM1-web.geojson'],
[resolve(__dirname, '..', 'data', 'boundaries', 'gb-IND-ADM1-full-web.geojson'), 'gb-IND-ADM1-full-web.geojson'],
]) if (existsSync(from)) cpSync(from, join(bDir, to))
console.log('[generate-tree] gathered boundary geojson')
@ -1579,6 +1585,9 @@ function main() {
'boundaries/gb-CHN-ADM1.geojson',
'boundaries/gb-IND-ADM1-full.geojson', // all 36 states (Cities with Internet's NFHS layer)
'boundaries/gb-IND-ADM1-full.meta.json', // its source and licence, served beside it
'boundaries/world-adm0-web.geojson', // the web copies the interactive maps fetch
'boundaries/gb-IND-ADM1-web.geojson',
'boundaries/gb-IND-ADM1-full-web.geojson',
// people-groups — all-people-groups.vue (the slim JSON only; NOT the 16 MB CSV,
// which no element fetches and which dropin.exclude already withholds).
'people-groups/people-groups-slim.json',

View file

@ -99,6 +99,41 @@ const visible = ref(false)
// which only the reader's × turns off: scrolling away hides the pill, scrolling back shows it again.
const onScreen = ref(true)
let mapIO = null
// Whether the map's pane is the ACTIVE view. A composite that stacks views (upg-100-mfe on a phone puts the
// map pane BEHIND Prayer Resources, both position:absolute; inset:0) passes the geometry test above while
// the reader sees only the posters, because an IntersectionObserver measures geometry, not occlusion. The
// contract: a composite marks an inactive view `inert` (or hidden / aria-hidden="true"), which is right for
// assistive technology anyway, and the pill hides while any ancestor of its map carries one of those.
const paneActive = ref(true)
let paneMOs = []
const INACTIVE_ATTRS = ['inert', 'hidden', 'aria-hidden']
function ancestorsOf(el) {
const out = []
let n = el
while (n) {
if (n.nodeType === 1) out.push(n)
n = n.parentNode || n.host || null // a ShadowRoot has no parentNode; continue at its host
}
return out
}
function isInactive(el) {
return ancestorsOf(el).some((a) => a.hasAttribute('inert') || a.hasAttribute('hidden') || a.getAttribute('aria-hidden') === 'true')
}
function watchPaneActive(box) {
const check = () => {
const now = !isInactive(box)
if (now !== paneActive.value) { paneActive.value = now; if (now) nextTick(clampIntoView) }
}
check()
if (typeof MutationObserver === 'undefined') return
for (const a of ancestorsOf(box)) {
try {
const mo = new MutationObserver(check)
mo.observe(a, { attributes: true, attributeFilter: INACTIVE_ATTRS })
paneMOs.push(mo)
} catch (_) { /* detached or foreign node: skip it */ }
}
}
const hintEl = ref(null)
let watchTimer = null
let movedTo = null // the element we re-parented into, so we can put ourselves back
@ -210,7 +245,7 @@ function sheetFloor(pill) {
function clampIntoView() {
const el = hintEl.value
if (!el || !onScreen.value) return
if (!el || !onScreen.value || !paneActive.value) return
try {
// ★ ADJUST `bottom`, NEVER `transform`. My first attempt set el.style.transform and cleared it
// between measurements — both wrong here, and the diagnosis took two failed builds:
@ -303,6 +338,7 @@ onMounted(() => {
// SHOW ONLY WHILE THE MAP IS IN VIEW. The observer's root is the viewport, and it clips by every
// scrolling ancestor, so a map pane scrolled out of a composite's column reads as not intersecting.
// Less than a fifth of the map on screen counts as away: the pill needs map under it.
if (box) watchPaneActive(box)
if (box && typeof IntersectionObserver !== 'undefined') {
try {
mapIO = new IntersectionObserver((entries) => {
@ -340,6 +376,8 @@ onMounted(() => {
onBeforeUnmount(() => {
if (watchTimer) clearInterval(watchTimer)
if (mapIO) { try { mapIO.disconnect() } catch (_) {} mapIO = null }
for (const mo of paneMOs) { try { mo.disconnect() } catch (_) {} }
paneMOs = []
if (attribRO) { try { attribRO.disconnect() } catch (_) {} attribRO = null } attribEl = null
if (hostRO) { try { hostRO.disconnect() } catch (_) {} hostRO = null }
if (sheetRO) { try { sheetRO.disconnect() } catch (_) {} sheetRO = null } sheetWatched = null
@ -400,7 +438,7 @@ useShadowStyles(`
</script>
<template>
<div v-if="visible" v-show="onScreen" ref="hintEl" class="pin-hint" :class="{ dark: isDark }" role="status">
<div v-if="visible" v-show="onScreen && paneActive" ref="hintEl" class="pin-hint" :class="{ dark: isDark }" role="status">
<span class="pin-hint-pill">
<span class="pin-hint-dot" aria-hidden="true"></span>
{{ label }}

View file

@ -0,0 +1,275 @@
<script setup>
/**
* RegionSectionCard.vue — one region as a card: a heading, a summary line, a grid of its countries,
* and a footer link. The DX-life /regions card, made reusable.
*
* WHY THIS IS IN library/. The Driver, verbatim: "let's say we want to reuse that on future plugins.
* We make it a reusable component, parameterized — you can set the region titles wherever you want
* them to be, fill in the countries to whatever you want them to be." The design is the point as much
* as the markup: the 156 lines of CSS below are lifted UNCHANGED from the regions plugin, so a caller
* gets the look, not a lookalike.
*
* ★ IT TAKES DATA, NOT AN API. The plugin version fetched its own regions and owned the layout, which
* is what made it unusable anywhere else. This takes `title`, `summary` and `countries` and emits what
* was clicked. Nothing here knows where regions come from.
*
* ★ THE CHEVRON IS INLINE ON PURPOSE, and this is the one thing a mover has to get right. The plugin
* declared <symbol id="chevron-up"> ONCE in its own template and every card referenced it with
* <use href="#chevron-up">. A shadow-root id is scoped to that root, so the same card mounted anywhere
* else would reference a symbol that does not exist — and an unresolved <use> renders NOTHING, with no
* error. The chevron would simply be missing. The plugin's own comment warns about exactly this class
* for external hrefs ("would 404 SILENTLY and the chevron would simply vanish"); a cross-root id
* reference fails the same way. So the path travels with the component.
*
* ★ RTL NEEDS AN ANCESTOR CARRYING dir, and that is a REQUIREMENT ON THE CONSUMER. The rules below
* include [dir="rtl"] .region-card__icon, ported because a chevron meaning "forward" must mirror in
* Arabic while a disclosure caret meaning "open/closed" must not — and the rtl --open rule is
* load-bearing, since [dir="rtl"] .region-card__icon is specificity 0-2-0 and would otherwise beat the
* 0-1-0 --open modifier and freeze the caret. The regions plugin satisfies this because .rl-root
* carries :dir itself. A new consumer must set dir on an ancestor inside its own shadow root, or the
* mirroring silently will not happen.
*
* ★ EXTRA CONTENT IS A SLOT, NOT A PROP. The regions plugin shows a country-poster wall under each
* card, configured with fourteen PosterWall props. Baking that in would have dragged one plugin's
* feature into every consumer's API. The `extra` slot keeps it where it belongs.
*/
import { useShadowStyles } from '../../composables/useShadowStyles.js'
const props = defineProps({
/** The region's display name. Already localized by the caller. */
title: { type: String, default: '' },
/** The line under the title, e.g. "12 countries · 480 groups". Caller-formatted. */
summary: { type: String, default: '' },
/** [{ name, count, slug }] — `count` is optional and rendered in parentheses when present. */
countries: { type: Array, default: () => [] },
/** The footer link's text, e.g. "View the region dashboard". Omit it to hide the footer. */
footerLabel: { type: String, default: '' },
/**
* OPTIONAL DISCLOSURE ROW, and the card owns it deliberately. The regions plugin needed a
* "View country posters" toggle under each card. The first cut put that row in the consumer's slot
* — which immediately meant copying this component's footer-link look and its rotating caret back
* into the plugin, i.e. re-creating the duplication this extraction exists to remove. A disclosure
* inside a card is a CARD feature; only the revealed CONTENT belongs to the consumer.
* Omit the label to hide the row entirely.
*/
disclosureLabel: { type: String, default: '' },
disclosureOpen: { type: Boolean, default: false },
/** id for aria-controls, so the button and the revealed region are really associated. */
disclosureId: { type: String, default: '' },
})
const emit = defineEmits(['select', 'select-country', 'toggle-disclosure'])
useShadowStyles(
// .region-card: DX-life's --border-radius-xlg (35px), overflow hidden, white body, no border --
// the dark header band supplies its own edge, unlike our old grey-outlined card.
'.region-card{ flex:0 0 auto; border-radius:var(--dx-radius); overflow:hidden; background:var(--dx-elevated); }' +
// .region-card__header: padding = --spacing-md / clamp(--spacing-md,4vw,--spacing-2xl) -- the clamp
// is KEPT as a clamp (var(--spacing-md) / var(--spacing-2xl) are the resolved ends), not frozen at its desktop maximum.
// gap = --spacing-2xs (7.5px), on --color-surface-brand.
'.region-card__header{ display:flex; flex-direction:column; gap:var(--spacing-2xs);' +
' padding:var(--spacing-md) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl));' +
' background:var(--dx-header-bg); color:var(--dx-header-text); }' +
// .region-card__title: font-size clamp(--font-size-xl,4vw,--font-size-3xl) = clamp(var(--font-size-xl),_,var(--font-size-3xl)).
// A phone gets the var(--font-size-xl) FLOOR, as theirs does; it only reaches var(--font-size-3xl) past ~1192px of width.
// DX-life's heading font (Bebas Neue -- a bold condensed display face), regular weight (the
// face itself carries the boldness), all-caps to match the source screenshot exactly.
// Bebas Neue itself is DX-life's own self-hosted font file (doxa.life/assets/fonts/BebasNeue/),
// not served with CORS -- cross-origin @font-face silently fails without it, so it cannot be
// loaded here without shipping a font asset this repo doesn't own. Approximated with the
// standard condensed-display-face fallback stack (bold weight + font-stretch:condensed + tight
// tracking), since Bebas Neue's glyphs read as heavy/compressed even at its nominal "400" weight.
// getComputedStyle() on the live card title reads weight 400 / line-height 1.0 (47.68px on a
// 47.69px font-size) / letter-spacing normal / text-transform none -- Bebas Neue's own glyphs
// are what read as bold+condensed+caps-like, DX-life applies NO transform on top of the font.
// Our fallback stack has no true Bebas-Neue substitute, so font-stretch:condensed stands in for
// the face's natural narrowness, but weight/leading/tracking/case now match the measured values
// exactly rather than compounding extra "boldness" the source doesn't have.
'.region-card__title{ margin:0; font-size:clamp(var(--font-size-xl), 4cqw, var(--font-size-3xl)); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; letter-spacing:normal; }' +
// Their link is an <a class="region-card__link | with-icon">, so it carries their `a` rule's
// font-weight: var(--font-weight-medium) = 500 (_globals.scss:132). Ours is a <button>
// because this element has no router, and `font:inherit` gave it 400 — MEASURED against
// their live /regions. Same class of miss as the dashboard back-link.
// The icon spacing is theirs too: .with-icon is inline-flex with align-items:baseline and NO
// gap (composition/_icon.scss:12), and the 1rem separation comes from
// `.with-icon .icon.right { margin-inline-start: 1rem }` — 10px at their 10px root. We had
// the same 10px via `gap`, which looks identical but means their rule would not apply, so
// the mechanism is now theirs: margin on the icon, gap left alone.
// ★ text-align:START, not left. Theirs is a NuxtLink, an inline <a> that shrink-wraps to its
// text; ours must be a <button> because this element changes the embed's subject in place instead
// of navigating a route. A button's UA default is text-align:center, so the port overrode it -- but
// it overrode it with the PHYSICAL `left`, which is identical to `start` in English and its
// opposite in Arabic. MEASURED on live /ar/regions vs ours at dir=rtl: same 246.9px row and the
// same 102.7px of text, their label flush to the row's RIGHT edge (gap 0px, the RTL reading start)
// and ours flush LEFT -- every label stranded 144.2px on the wrong side. Invisible in English,
// which is exactly why it survived a 37-property card diff and an 11-property bullet diff.
'.region-card__link{ appearance:none; background:none; border:0; padding:0; margin:0; text-align:start;' +
' cursor:pointer; color:inherit; font:inherit; font-weight:500;' +
' display:inline-flex; align-items:baseline; }' +
'.region-card__link:hover{ color:#92b195; }' +
'.region-card__link:focus-visible{ outline:2px solid #fff; outline-offset:2px; }' +
// .region-card__icon: DX-life's own <svg class="icon | rotate-90 right"><use href="arrow-
// chevron.svg#chevron-up"></use></svg> -- a REAL chevron path (viewBox 0 0 489.67 289.877),
// not a text glyph. Ported verbatim (fill:currentColor tracks the surrounding text colour,
// same as their `.icon{ fill:currentColor }` utility) and rotated 90deg to point right, same
// as their `rotate-90` class. getComputedStyle measured it at 33.38px square on the live page.
// ★ 1rem WAS THE ONE UNIT IN THIS PORT THAT INHERITS THE HOST PAGE'S ROOT, and it was wrong on
// our own leaf, not only in an embed. `rem` resolves against the DOCUMENT root wherever the
// element lives — which is the whole reason dxlife-tokens.js resolves their scale to px — and
// their root is 10px (html{font-size: var(--font-size-base)}, $font-size-base: 10px). Ours
// inherits whatever the host sets: 16px by default, so this gap rendered 16px against their
// 10px, and on a host with the common html{font-size:62.5%} reset it would render 10px by
// accident. Pinned to the value their root produces, like every other token here.
// No diff caught it: the list diff's `link` spec reads colour, decoration, weight, display and
// alignItems — no margin — so the icon's own spacing was never a measured property. It is now.
// (Credit where due: the class of bug came to me as a flock relay from another agent's sweep.)
'.region-card__icon{ width:0.7em; height:0.7em; flex:none; fill:currentColor; transform:rotate(90deg);' +
' margin-inline-start:10px; }' +
// .region-card__summary: --font-size-md (var(--font-size-md)), 0.85 opacity, on the header band.
'.region-card__summary{ font-size:var(--font-size-md); opacity:0.85; margin:0; }' +
// ★ 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. We ship a `ru` locale and had neither the rule nor the font.
// ONLY the headings, exactly as theirs — their .region-stat__value is a <p> and keeps Bebas even in
// Russian, which is their behaviour and so is ours.
// 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"] .rl-title, [lang="ru-RU"] .rl-title,' +
'[lang="ru"] .region-card__title, [lang="ru-RU"] .region-card__title{ font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' letter-spacing:-0.02em; }' +
// .region-card__body: padding --spacing-lg / clamp(...) / --spacing-xl -> var(--spacing-lg) / clamp / var(--spacing-xl)
'.region-card__body{ padding:var(--spacing-lg) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-xl); }' +
// .countries-grid: DX-life's track is minmax(220px,1fr). Gap --spacing-xs / --spacing-xl
// (10px / var(--spacing-xl)). getComputedStyle on the live grid shows padding-left:35.16px -- that's the
// page's global `ul` reset (bullets kept, not list-style:none), so the whole grid is indented
// one list-item's worth even though DX-life's countries-grid a rule doesn't set it directly.
// Reproduced with list-style:none + the same 35.16px indent kept explicitly so a real list
// bullet has somewhere to sit without stealing width from the text column.
// padding-INLINE-start, as theirs is (1.8em). The physical `padding-left:35.2px` it replaces
// put the indent on the wrong side in Arabic, where the bullets hang off the right edge.
'.countries-grid{ list-style:none; margin:0; padding-inline-start:1.8em; display:grid;' +
' grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px var(--spacing-xl); }' +
// Plain inline text (not a flex row) so multi-line country names wrap the same way DX-life's
// <NuxtLink> text does -- a flex row with align-items:baseline pinned the bullet to the first
// line only and stranded the (count) span beside the second line on wrap. font-weight:500
// matches getComputedStyle() on the live country links (theirs is medium, not regular).
// Their country entries are bare <NuxtLink>s inside .countries-grid li — no class of their
// own — so they take their `a` rule: color var(--color-brand-light) = $color-green-400 =
// #4e594f, font-weight medium. MEASURED on their live /regions: rgb(78, 89, 79). Ours used
// var(--dx-text), which resolves to rgb(42, 51, 42) — the body ink, not their link ink.
'.country-link{ appearance:none; background:none; border:0; padding:2px 0; margin:0;' +
' text-align:start; cursor:pointer; color:#4e594f; font:inherit; font-size:var(--font-size-md);' +
' font-weight:500; width:100%; position:relative; }' +
// inset-inline-start, NOT left: the bullet hangs off the text's starting edge, which is the
// RIGHT edge in Arabic. With `left:-16px` it stayed on the physical left in RTL — measured
// at locale=ar, computed right was 256.938px instead of the -16px near-edge overhang.
// ★ THEIR BULLET, COPIED — not a dot of my own. base/_globals.scss:117-131 paints the list
// marker with a 16x16 data-URI SVG circle in #92b195 sized to 1.5em, hung off the text's start
// edge by margin-inline-start:-1.3em (-2em in RTL), top .3em, line-height .9. Their
// .countries-grid comment is explicit that keeping it is deliberate ("Keeps the site's list
// bullets (global ul padding + ::before dot)").
// I had re-created it as a 6px border-radius:50% square, and MEASURED against live it was wrong
// in 9 of 11 properties: 6px vs 29.2969px box, solid fill vs a transparent box carrying the
// image, top 11.72px vs 5.86px, and positioned with inset-inline-start:-16px instead of their
// negative margin. Not a near-miss — a different bullet.
// The rule is a double-quoted JS string because the data URI contains single quotes.
".country-link::before{ content:''; position:absolute; top:0.3em; width:1.5em; height:1.5em;" +
" line-height:0.9; margin-inline-start:-1.3em; background-repeat:no-repeat;" +
" background-image:url(\"data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='%2392b195' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='8' cy='8' r='7' stroke='%2392b195' stroke-width='1.5'/%3E%3C/svg%3E\"); }" +
// Their RTL variant. It is a separate rule here rather than a nested [dir="rtl"] & because this
// CSS lives in a shadow root, where a [dir] selector on an outer ancestor does not reach in.
'[dir="rtl"] .country-link::before{ margin-inline-start:-2em; }' +
'.country-link:hover{ text-decoration:underline; text-decoration-color:#92b195; }' +
'.country-link:focus-visible{ outline:2px solid #92b195; outline-offset:2px; border-radius:4px; }' +
'.country-link__count{ color:var(--dx-muted); }' +
// .region-card__footer: DX-life's exact rule -- flex, flex-end, margin-top --spacing-lg (var(--spacing-lg)),
// NO margin-bottom. This element is a <p>; its user-agent default margin-block-end (~var(--font-size-md) at
// this font-size) was leaking through because only margin-top was ever set -- pin margin:0
// first, then apply margin-top, so the bottom UA default can't survive the cascade.
// The toggle row and the wall it opens. The caret rotates rather than swapping glyphs,
// which is what makes open/closed legible at a glance.
'.region-card__posters-toggle{ margin-top:0; }' +
'.region-card__icon--open{ transform:rotate(180deg); }' +
// RTL MIRRORING, ported from their utilities/_shapes.scss:
// .rotate-90 { transform:rotate(90deg); [dir="rtl"] & { transform:rotate(-90deg); } }
// Their .rotate-180 has NO rtl variant, and that distinction is the whole rule: a chevron that
// means "forward" must mirror, a disclosure caret that means "open/closed" must not. MEASURED
// on live doxa.life: .region-card__link svg is matrix(0,1,-1,0,0,0) at /regions and
// matrix(0,-1,1,0,0,0) at /ar/regions — ours stayed at +90deg in Arabic, so the chevron sat on
// the correct side pointing the WRONG WAY. Found by looking at the Arabic screenshot.
// The selector works inside our shadow root because .rl-root carries :dir itself.
'[dir="rtl"] .region-card__icon{ transform:rotate(-90deg); }' +
// ...and this one is load-bearing: [dir="rtl"] .region-card__icon is specificity 0-2-0, which
// would otherwise beat the 0-1-0 --open modifier and freeze the poster caret in Arabic.
'[dir="rtl"] .region-card__icon--open{ transform:rotate(180deg); }' +
'.region-card__posters{ margin-top:13.3px; }' +
'.region-card__footer{ display:flex; justify-content:flex-end; margin:0; margin-top:var(--spacing-lg);' +
' padding:0 clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-lg); }' +
'.region-card__footer-link{ appearance:none; background:none; border:0; padding:0; margin:0;' +
' cursor:pointer; font:inherit; font-size:var(--font-size-md); font-weight:500; color:var(--dx-link);' +
' display:inline-flex; align-items:baseline; gap:10px; text-decoration:underline; text-decoration-thickness:2px; }' +
'.region-card__footer-link:hover{ color:var(--dx-header-bg); }' +
'.region-card__footer-link:focus-visible{ outline:2px solid var(--dx-link); outline-offset:2px; border-radius:4px; }',
'regions-list'
, 'region-section-card')
</script>
<template>
<div class="region-card">
<div class="region-card__header">
<h2 class="region-card__title">
<button type="button" class="region-card__link" @click="emit('select')">
{{ title }}
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-10.323,10.323-10.323,27.056,0,37.379,10.323,10.323,27.056,10.323,37.379,0l161.068-161.068,161.068,161.068c10.323,10.323,27.056,10.323,37.379,0,10.277-10.324,10.277-27.011-.238-37.333Z"/>
</svg>
</button>
</h2>
<p v-if="summary" class="region-card__summary">{{ summary }}</p>
</div>
<div class="region-card__body">
<ul class="countries-grid">
<li v-for="c in countries" :key="c.slug || c.name">
<button type="button" class="country-link" @click="emit('select-country', c)">
{{ c.name }}<span v-if="c.count != null" class="country-link__count"> ({{ c.count }})</span>
</button>
</li>
</ul>
</div>
<p v-if="footerLabel" class="region-card__footer">
<button type="button" class="region-card__footer-link" @click="emit('select')">
{{ footerLabel }}
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-10.323,10.323-10.323,27.056,0,37.379,10.323,10.323,27.056,10.323,37.379,0l161.068-161.068,161.068,161.068c10.323,10.323,27.056,10.323,37.379,0,10.277-10.324,10.277-27.011-.238-37.333Z"/>
</svg>
</button>
</p>
<p v-if="disclosureLabel" class="region-card__footer region-card__posters-toggle">
<button
type="button"
class="region-card__footer-link"
:aria-expanded="disclosureOpen ? 'true' : 'false'"
:aria-controls="disclosureId || undefined"
@click="emit('toggle-disclosure')"
>
{{ disclosureLabel }}
<svg class="region-card__icon" :class="{ 'region-card__icon--open': disclosureOpen }" viewBox="0 0 489.67 289.877" aria-hidden="true">
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-10.323,10.323-10.323,27.056,0,37.379,10.323,10.323,27.056,10.323,37.379,0l161.068-161.068,161.068,161.068c10.323,10.323,27.056,10.323,37.379,0,10.277-10.324,10.277-27.011-.238-37.333Z"/>
</svg>
</button>
</p>
<!-- Only the revealed CONTENT is the consumer's. The regions plugin puts its PosterWall here. -->
<div v-if="disclosureOpen" :id="disclosureId || undefined" class="region-card__posters">
<slot name="extra" />
</div>
</div>
</template>

View file

@ -161,7 +161,7 @@ export function useCountryOutline(getMap, opts = {}) {
// Add EMPTY first, then setData after the fetch, so addLayer below stays SYNCHRONOUS and
// the outline keeps its place in the layer order.
m.addSource(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
fetch(`${resolveAssetsBase(null)}/boundaries/world-adm0.geojson`)
fetch(`${resolveAssetsBase(null)}/boundaries/world-adm0-web.geojson`)
.then((r) => r.json())
.then((gj) => {
// The tileset names the ISO field iso_3166_1_alpha_3; the geojson names it iso3.

View file

@ -434,7 +434,7 @@ export function useMapLayers(options = {}) {
// EMPTY first so the addLayer below stays synchronous and keeps its z-order slot
// (regions → shadow → pins → hitbox); setData once the fetch lands.
map.addSource('regions', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
fetch(`${resolveAssetsBase(null)}/boundaries/world-adm0.geojson`)
fetch(`${resolveAssetsBase(null)}/boundaries/world-adm0-web.geojson`)
.then((r) => r.json())
.then((gj) => {
// Tileset field is iso_3166_1_alpha_3; the geojson's is iso3. Normalise ONCE

View file

@ -45,7 +45,7 @@
* See documentation/feedback.md → Section A for the design rationale.
*/
import { watch } from 'vue'
import { watch, effectScope } from 'vue'
import { createI18n } from 'vue-i18n'
// Eager glob — all locale subfolders inlined at build time
@ -154,7 +154,14 @@ export function createAppI18n() {
document.documentElement.dir = 'rtl'
}
} catch { /* no document (SSR/tests) — nothing to reflect */ }
const i18n = createI18n({
// ★ A DETACHED EFFECT SCOPE (P0, the Driver's console, 2026-09-27). vue-i18n's createGlobal() runs
// inside scope.run() and throws UNEXPECTED_ERROR ("SyntaxError: Unexpected error") when that returns
// undefined; Vue 3.5 creates a new EffectScope INACTIVE when the currently active scope is STOPPED.
// A custom element's connectedCallback -> configureApp -> here can run while a released tab's stopped
// scope is still current (Plugin Studio's gallery, every tab after the first). effectScope(true) never
// inherits the ambient scope, so this instance and its locale watcher are always created live.
const i18nScope = effectScope(true)
const i18n = i18nScope.run(() => createI18n({
legacy: false,
globalInjection: true,
locale,
@ -162,7 +169,7 @@ export function createAppI18n() {
messages,
missingWarn: false,
fallbackWarn: false,
})
}))
// ★ INSTALLING i18n ALSO PROVIDES IT, so a bundle CANNOT forget. This is the durable half of a
// bug that is invisible by construction.
@ -195,12 +202,17 @@ export function createAppI18n() {
// Watching the instance's own locale ref is the single choke point: ProfileLoader, the SPA's
// picker and the studio's picker all move THIS ref, so none of them has to remember to also tell
// the static reader.
watch(i18n.global.locale, (l) => setRuntimeLocale(l), { immediate: true })
i18nScope.run(() => watch(i18n.global.locale, (l) => setRuntimeLocale(l), { immediate: true }))
const install = i18n.install.bind(i18n)
i18n.install = (app, ...rest) => {
const out = install(app, ...rest)
app.provide('appI18n', i18n)
// Several bundle entries still call app.provide('appI18n', i18n) themselves after app.use(i18n). The
// value is the same instance, but Vue warns "App already provides property with key appI18n" on every
// mount. A second provide of THIS instance under that key is made a no-op; anything else passes through.
const provide = app.provide.bind(app)
app.provide = (key, value) => (key === 'appI18n' && value === i18n ? app : provide(key, value))
return out
}
return i18n

View file

@ -131,7 +131,8 @@
"t_deaf": "فجوة الصم",
"t_resources": "الموارد حسب الشعب",
"t_languages": "اللغات حسب البلد",
"t_growth": "الوصول مقابل نمو السكان"
"t_growth": "الوصول مقابل نمو السكان",
"t_bridgereach": "لغة واحدة، كم شعبًا؟"
},
"pages": {
"listTitle": "موارد الإنجيل حسب اللغة",

View file

@ -113,7 +113,13 @@
"colCountry": "الدولة",
"colLanguage": "اللغة",
"colPopulation": "عدد السكان",
"colReligion": "الدين"
"colReligion": "الدين",
"colPctChristian": "% مسيحيون",
"colAdjacency": "التجاور",
"colCode": "الرمز",
"sortMobilization": "التعبئة",
"sortFrontier": "المناطق الحدودية أولًا",
"colName": "الاسم"
},
"fc": {
"clusterBy": "التجميع حسب",
@ -124,7 +130,17 @@
"legendPetal": "جار مُبشَّر (تفصيلي، إحداثيات حقيقية)",
"legendHelp": "انقر دائرة عليا لإعادة التجميع · انقر نواة لتتفتح بتلاتها",
"clustering": "جارٍ تجميع الحصاد حسب اللغة…",
"signLanguage": "لغة الإشارة"
"signLanguage": "لغة الإشارة",
"mode": {
"family": "العائلة",
"language": "اللغة",
"dialect": "اللهجة"
},
"size": {
"fewerBigger": "أقل · أكبر",
"more": "أكثر",
"most": "الأكثر"
}
},
"mc": {
"greenEncoding": "ترميز اللون الأخضر",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "عرض الخريطة أو الزهرة",
"loading": "جارٍ تحميل حقل الحصاد…",
"unreachedTarget": "هدف غير مُبشَّر"
"unreachedTarget": "هدف غير مُبشَّر",
"map": "الخريطة",
"flower": "الزهرة",
"details": "التفاصيل",
"senders": "مُرسِلون ←",
"clustersHidden": "مجموعات · {n} مخفية",
"legendSend": "SEND (متصل = اللغة نفسها · متقطع = مجموعة · منقط = داخل الدولة)",
"legendSelection": "التحديد · نصف القطر = لوغاريتم السكان · لوقا 10:2",
"navySend": "الكحلي = SEND",
"goldMesh": "الذهبي = شبكة اللغة الواحدة"
},
"flower": {
"pickTarget": "اختر هدفًا أحمر على الخريطة —",

View file

@ -32,5 +32,61 @@
"readyPins": "جاهز · دبابيس الشعوب في الدول: {pins} · انقر أي دبوس",
"readyLanguage": "جاهز · الدبابيس: {pins} · تغطية العائلات {coverage}% · أعلى 25 = {top}%",
"readyReligion": "الدبابيس: {pins} · الأكثر شيوعًا: {religion} ({count})"
},
"ui": {
"chooseView": "اختر عرضًا",
"search": "ابحث بالشعب أو المكان أو اللغة أو الدين…",
"baseMap": "الخريطة الأساسية",
"dataSources": "مصادر البيانات",
"legend": "مفتاح الخريطة",
"openLegend": "افتح مفتاح الخريطة",
"clearSelectionEsc": "إلغاء التحديد (Esc)",
"closeEsc": "إغلاق (Esc)",
"close": "إغلاق",
"retry": "أعد المحاولة",
"loadingMap": "جارٍ تحميل الخريطة",
"flyToPin": "انتقل إلى الدبوس",
"primaryReligion": "الدين الرئيسي",
"ropHierarchy": "تسلسل ROP الهرمي"
},
"pill": {
"religion": "توزيع الأديان · انقر دبوسًا للتفاصيل",
"ropTree": "تسلسل ROP الهرمي · الكتلة ← المجموعة ← الشعب ← PGIC",
"languageFamilies": "العائلات العرقية اللغوية (PPLR) · انقر دبوسًا للتفاصيل",
"sunburst": "تسلسل ROP الهرمي · الكتلة ← مجموعة الشعوب · مرّر للتكبير",
"pins": "كل دبابيس الشعوب في الدول · انقر لمعرّفات الأنظمة",
"coverage": "تغطية الترجمة · التظليل = متوسط اللغات المعبّأة لكل دولة · انقر دبوسًا أو دولة",
"rosetta": "جدول معرّفات الأنظمة · اللون = المعرّفات الموجودة · ★ = صف نموذجي"
},
"legendTitle": {
"avgLocales": "متوسط اللغات / {n}",
"idCoverage": "تغطية المعرّفات"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD، الثلاثة",
"imbOnly": "IMB فقط",
"jpOnly": "JP فقط",
"neither": "لا هذا ولا ذاك"
},
"samplePg": "★ شعب نموذجي",
"missingIds": "لم تُربط بعد في PPLR: {ids}. الصف الفارغ نقص في البيانات وليس خطأ."
},
"popup": {
"noName": "(بلا اسم)",
"country": "الدولة",
"religion": "الدين",
"language": "اللغة",
"family": "العائلة",
"affinityBloc": "الكتلة",
"cluster": "المجموعة",
"peopleCluster": "مجموعة الشعوب",
"peopleGroup": "الشعب",
"pgCount": "الشعوب",
"avgLocalesFilled": "متوسط اللغات المعبّأة",
"iso3Country": "رمز الدولة ISO-3"
},
"card": {
"nameInLocales": "الاسم في {n} لغة"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "Die Gehörlosen-Lücke",
"t_resources": "Ressourcen nach Volksgruppe",
"t_languages": "Sprachen nach Land",
"t_growth": "Zugang vs. Bevölkerungswachstum"
"t_growth": "Zugang vs. Bevölkerungswachstum",
"t_bridgereach": "Eine Sprache, wie viele Völker"
},
"pages": {
"listTitle": "Evangeliums-Ressourcen nach Sprache",

View file

@ -113,7 +113,13 @@
"colCountry": "Land",
"colLanguage": "Sprache",
"colPopulation": "Bevölkerung",
"colReligion": "Religion"
"colReligion": "Religion",
"colPctChristian": "% Christen",
"colAdjacency": "Nachbarschaft",
"colCode": "Kennung",
"sortMobilization": "Mobilisierung",
"sortFrontier": "Grenzgebiete zuerst",
"colName": "Name"
},
"fc": {
"clusterBy": "Gruppieren nach",
@ -124,7 +130,17 @@
"legendPetal": "Erreichter Nachbar (fein, echte Koordinaten)",
"legendHelp": "oberen Kreis anklicken zum Neugruppieren · Kern anklicken, um die Blütenblätter zu öffnen",
"clustering": "Die Ernte wird nach Sprache gruppiert…",
"signLanguage": "Gebärdensprache"
"signLanguage": "Gebärdensprache",
"mode": {
"family": "Familie",
"language": "Sprache",
"dialect": "Dialekt"
},
"size": {
"fewerBigger": "weniger · größer",
"more": "mehr",
"most": "am meisten"
}
},
"mc": {
"greenEncoding": "Kodierung der Grünfarbe",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Karten- oder Blumenansicht",
"loading": "Erntefeld wird geladen…",
"unreachedTarget": "unerreichtes Ziel"
"unreachedTarget": "unerreichtes Ziel",
"map": "Karte",
"flower": "Blume",
"details": "Details",
"senders": "Sender →",
"clustersHidden": "Cluster · {n} ausgeblendet",
"legendSend": "SEND (durchgezogen = gleiche Sprache · gestrichelt = Cluster · gepunktet = im Land)",
"legendSelection": "Auswahl · Radius = log. Bevölkerung · Lukas 10,2",
"navySend": "Marineblau = SEND",
"goldMesh": "Gold = Netz gleicher Sprache"
},
"flower": {
"pickTarget": "Ein rotes Ziel auf der Karte wählen —",

View file

@ -32,5 +32,61 @@
"readyPins": "Bereit · Pins: {pins} · einen Pin anklicken",
"readyLanguage": "Bereit · Pins: {pins} · Familienabdeckung {coverage} % · Top 25 = {top} %",
"readyReligion": "Pins: {pins} · am häufigsten: {religion} ({count})"
},
"ui": {
"chooseView": "Ansicht wählen",
"search": "Nach Volk, Ort, Sprache, Religion suchen…",
"baseMap": "Grundkarte",
"dataSources": "Datenquellen",
"legend": "Legende",
"openLegend": "Legende öffnen",
"clearSelectionEsc": "Auswahl aufheben (Esc)",
"closeEsc": "Schließen (Esc)",
"close": "Schließen",
"retry": "Erneut versuchen",
"loadingMap": "Karte wird geladen",
"flyToPin": "Zum Pin fliegen",
"primaryReligion": "Hauptreligion",
"ropHierarchy": "ROP-Hierarchie"
},
"pill": {
"religion": "Verteilung der Religionen · Pin anklicken für Details",
"ropTree": "ROP-Hierarchie · Affinitätsblock → Cluster → Volk → PGIC",
"languageFamilies": "Ethnolinguistische Familien (PPLR) · Pin anklicken für Details",
"sunburst": "ROP-Hierarchie · Affinitätsblock → Volkscluster · scrollen zum Zoomen",
"pins": "Alle Volksgruppen-Pins je Land · anklicken für systemübergreifende IDs",
"coverage": "Übersetzungsabdeckung · Färbung = durchschnittlich gefüllte Sprachen je Land · Pin oder Land anklicken",
"rosetta": "ID-Rosetta · Farbe = vorhandene IDs · ★ = Beispielzeile"
},
"legendTitle": {
"avgLocales": "Durchschnitt Sprachen / {n}",
"idCoverage": "ID-Abdeckung"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, alle drei",
"imbOnly": "Nur IMB",
"jpOnly": "Nur JP",
"neither": "keins von beiden"
},
"samplePg": "★ Beispiel-Volk",
"missingIds": "In PPLR noch nicht zugeordnet: {ids}. Eine leere Zeile ist eine Datenlücke, kein Fehler."
},
"popup": {
"noName": "(ohne Namen)",
"country": "Land",
"religion": "Religion",
"language": "Sprache",
"family": "Familie",
"affinityBloc": "Affinitätsblock",
"cluster": "Cluster",
"peopleCluster": "Volkscluster",
"peopleGroup": "Volk",
"pgCount": "Völker",
"avgLocalesFilled": "Durchschnittlich gefüllte Sprachen",
"iso3Country": "ISO-3-Land"
},
"card": {
"nameInLocales": "Name in {n} Sprachen"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "The Deaf Gap",
"t_resources": "Resources by People Group",
"t_languages": "Languages by Country",
"t_growth": "Access vs Population Growth"
"t_growth": "Access vs Population Growth",
"t_bridgereach": "One Language, How Many Peoples"
},
"pages": {
"listTitle": "Gospel Resources by Language",

View file

@ -113,7 +113,13 @@
"colCountry": "Country",
"colLanguage": "Language",
"colPopulation": "Population",
"colReligion": "Religion"
"colReligion": "Religion",
"colPctChristian": "% Christian",
"colAdjacency": "Adjacency",
"colCode": "Code",
"sortMobilization": "Mobilization",
"sortFrontier": "Frontier first",
"colName": "Name"
},
"fc": {
"clusterBy": "Cluster by",
@ -124,7 +130,17 @@
"legendPetal": "Reached neighbor (granular, real coordinates)",
"legendHelp": "click a top circle to re-cluster · click a core to bloom its petals",
"clustering": "Clustering the harvest by language…",
"signLanguage": "Sign Language"
"signLanguage": "Sign Language",
"mode": {
"family": "Family",
"language": "Language",
"dialect": "Dialect"
},
"size": {
"fewerBigger": "fewer · bigger",
"more": "more",
"most": "most"
}
},
"mc": {
"greenEncoding": "Green color encoding",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Map or flower view",
"loading": "Loading the harvest field…",
"unreachedTarget": "unreached target"
"unreachedTarget": "unreached target",
"map": "Map",
"flower": "Flower",
"details": "Details",
"senders": "senders →",
"clustersHidden": "clusters · {n} hidden",
"legendSend": "SEND (solid = same language · dashed = cluster · dotted = in-country)",
"legendSelection": "selection · radius = log of population · Luke 10:2",
"navySend": "navy = SEND",
"goldMesh": "gold = same-language mesh"
},
"flower": {
"pickTarget": "Pick a red target on the map —",

View file

@ -32,5 +32,61 @@
"readyPins": "Ready · {pins} PG-in-country pins · click any pin",
"readyLanguage": "Ready · {pins} pins · {coverage}% family coverage · top-25 = {top}%",
"readyReligion": "{pins} pins · {religion} most common ({count})"
},
"ui": {
"chooseView": "Choose a view",
"search": "Search by people, place, language, religion…",
"baseMap": "Base map",
"dataSources": "Data sources",
"legend": "Legend",
"openLegend": "Open legend",
"clearSelectionEsc": "Clear selection (Esc)",
"closeEsc": "Close (Esc)",
"close": "Close",
"retry": "Retry",
"loadingMap": "Loading map",
"flyToPin": "Fly to pin",
"primaryReligion": "Primary religion",
"ropHierarchy": "ROP hierarchy"
},
"pill": {
"religion": "Religion distribution · click a pin for detail",
"ropTree": "ROP hierarchy · Affinity Bloc → Cluster → People Group → PGIC",
"languageFamilies": "Ethnolinguistic families (PPLR) · click a pin for detail",
"sunburst": "ROP hierarchy · Affinity Bloc → People Cluster · scroll to zoom",
"pins": "All people-group-in-country pins · click for cross-system IDs",
"coverage": "Translation coverage · shading = average locales filled per country · click a pin or a country",
"rosetta": "Cross-system ID Rosetta · colour = which IDs exist · ★ = sample row"
},
"legendTitle": {
"avgLocales": "Average locales / {n}",
"idCoverage": "ID coverage"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, all three",
"imbOnly": "IMB only",
"jpOnly": "JP only",
"neither": "neither"
},
"samplePg": "★ sample people group",
"missingIds": "Not yet cross-mapped in PPLR: {ids}. An empty row is a data gap, not an error."
},
"popup": {
"noName": "(no name)",
"country": "Country",
"religion": "Religion",
"language": "Language",
"family": "Family",
"affinityBloc": "Affinity Bloc",
"cluster": "Cluster",
"peopleCluster": "People cluster",
"peopleGroup": "People group",
"pgCount": "People groups",
"avgLocalesFilled": "Average locales filled",
"iso3Country": "ISO-3 country"
},
"card": {
"nameInLocales": "Name in {n} locales"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "La brecha sorda",
"t_resources": "Recursos por pueblo",
"t_languages": "Idiomas por país",
"t_growth": "Acceso vs crecimiento"
"t_growth": "Acceso vs crecimiento",
"t_bridgereach": "Una lengua, cuántos pueblos"
},
"pages": {
"listTitle": "Recursos del evangelio por idioma",

View file

@ -113,7 +113,13 @@
"colCountry": "País",
"colLanguage": "Idioma",
"colPopulation": "Población",
"colReligion": "Religión"
"colReligion": "Religión",
"colPctChristian": "% cristianos",
"colAdjacency": "Adyacencia",
"colCode": "Código",
"sortMobilization": "Movilización",
"sortFrontier": "Frontera primero",
"colName": "Nombre"
},
"fc": {
"clusterBy": "Agrupar por",
@ -124,7 +130,17 @@
"legendPetal": "Vecino alcanzado (detallado, coordenadas reales)",
"legendHelp": "haz clic en un círculo superior para reagrupar · haz clic en un núcleo para abrir sus pétalos",
"clustering": "Agrupando la cosecha por idioma…",
"signLanguage": "Lengua de señas"
"signLanguage": "Lengua de señas",
"mode": {
"family": "Familia",
"language": "Idioma",
"dialect": "Dialecto"
},
"size": {
"fewerBigger": "menos · más grandes",
"more": "más",
"most": "la mayoría"
}
},
"mc": {
"greenEncoding": "Codificación del color verde",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Vista de mapa o de flor",
"loading": "Cargando el campo de la cosecha…",
"unreachedTarget": "objetivo no alcanzado"
"unreachedTarget": "objetivo no alcanzado",
"map": "Mapa",
"flower": "Flor",
"details": "Detalles",
"senders": "emisores →",
"clustersHidden": "clústeres · {n} ocultos",
"legendSend": "SEND (continua = mismo idioma · discontinua = clúster · punteada = en el país)",
"legendSelection": "selección · radio = log de la población · Lucas 10:2",
"navySend": "azul marino = SEND",
"goldMesh": "dorado = red de un mismo idioma"
},
"flower": {
"pickTarget": "Elige un objetivo rojo en el mapa —",

View file

@ -32,5 +32,61 @@
"readyPins": "Listo · pines: {pins} · haz clic en un pin",
"readyLanguage": "Listo · pines: {pins} · cobertura de familias {coverage} % · top 25 = {top} %",
"readyReligion": "Pines: {pins} · más común: {religion} ({count})"
},
"ui": {
"chooseView": "Elige una vista",
"search": "Buscar por pueblo, lugar, idioma, religión…",
"baseMap": "Mapa base",
"dataSources": "Fuentes de datos",
"legend": "Leyenda",
"openLegend": "Abrir la leyenda",
"clearSelectionEsc": "Borrar la selección (Esc)",
"closeEsc": "Cerrar (Esc)",
"close": "Cerrar",
"retry": "Reintentar",
"loadingMap": "Cargando el mapa",
"flyToPin": "Ir al pin",
"primaryReligion": "Religión principal",
"ropHierarchy": "Jerarquía ROP"
},
"pill": {
"religion": "Distribución de religiones · haz clic en un pin para ver detalles",
"ropTree": "Jerarquía ROP · bloque de afinidad → clúster → pueblo → PGIC",
"languageFamilies": "Familias etnolingüísticas (PPLR) · haz clic en un pin para ver detalles",
"sunburst": "Jerarquía ROP · bloque de afinidad → clúster de pueblos · desplázate para ampliar",
"pins": "Todos los pines de pueblos por país · haz clic para ver los identificadores de cada sistema",
"coverage": "Cobertura de traducción · sombreado = promedio de idiomas completados por país · haz clic en un pin o un país",
"rosetta": "Rosetta de identificadores · color = identificadores existentes · ★ = fila de ejemplo"
},
"legendTitle": {
"avgLocales": "Promedio de idiomas / {n}",
"idCoverage": "Cobertura de identificadores"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, los tres",
"imbOnly": "Solo IMB",
"jpOnly": "Solo JP",
"neither": "ninguno"
},
"samplePg": "★ pueblo de ejemplo",
"missingIds": "Aún sin vincular en PPLR: {ids}. Una fila vacía es un hueco en los datos, no un error."
},
"popup": {
"noName": "(sin nombre)",
"country": "País",
"religion": "Religión",
"language": "Idioma",
"family": "Familia",
"affinityBloc": "Bloque de afinidad",
"cluster": "Clúster",
"peopleCluster": "Clúster de pueblos",
"peopleGroup": "Pueblo",
"pgCount": "Pueblos",
"avgLocalesFilled": "Promedio de idiomas completados",
"iso3Country": "País ISO-3"
},
"card": {
"nameInLocales": "Nombre en {n} idiomas"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "L'écart sourd",
"t_resources": "Ressources par peuple",
"t_languages": "Langues par pays",
"t_growth": "Accès vs croissance"
"t_growth": "Accès vs croissance",
"t_bridgereach": "Une langue, combien de peuples"
},
"pages": {
"listTitle": "Ressources de l'Évangile par langue",

View file

@ -113,7 +113,13 @@
"colCountry": "Pays",
"colLanguage": "Langue",
"colPopulation": "Population",
"colReligion": "Religion"
"colReligion": "Religion",
"colPctChristian": "% chrétiens",
"colAdjacency": "Proximité",
"colCode": "Code",
"sortMobilization": "Mobilisation",
"sortFrontier": "Frontières d’abord",
"colName": "Nom"
},
"fc": {
"clusterBy": "Regrouper par",
@ -124,7 +130,17 @@
"legendPetal": "Voisin atteint (détaillé, coordonnées réelles)",
"legendHelp": "cliquez sur un cercle du haut pour regrouper · cliquez sur un cœur pour ouvrir ses pétales",
"clustering": "Regroupement de la moisson par langue…",
"signLanguage": "Langue des signes"
"signLanguage": "Langue des signes",
"mode": {
"family": "Famille",
"language": "Langue",
"dialect": "Dialecte"
},
"size": {
"fewerBigger": "moins · plus grands",
"more": "plus",
"most": "le plus"
}
},
"mc": {
"greenEncoding": "Codage de la couleur verte",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Vue carte ou fleur",
"loading": "Chargement du champ de la moisson…",
"unreachedTarget": "cible non atteinte"
"unreachedTarget": "cible non atteinte",
"map": "Carte",
"flower": "Fleur",
"details": "Détails",
"senders": "envoyeurs →",
"clustersHidden": "grappes · {n} masqués",
"legendSend": "SEND (plein = même langue · tirets = grappe · pointillé = dans le pays)",
"legendSelection": "sélection · rayon = log de la population · Luc 10.2",
"navySend": "bleu marine = SEND",
"goldMesh": "or = réseau de même langue"
},
"flower": {
"pickTarget": "Choisissez une cible rouge sur la carte —",

View file

@ -32,5 +32,61 @@
"readyPins": "Prêt · épingles : {pins} · cliquez sur une épingle",
"readyLanguage": "Prêt · épingles : {pins} · couverture des familles {coverage} % · top 25 = {top} %",
"readyReligion": "Épingles : {pins} · la plus fréquente : {religion} ({count})"
},
"ui": {
"chooseView": "Choisir une vue",
"search": "Rechercher par peuple, lieu, langue, religion…",
"baseMap": "Fond de carte",
"dataSources": "Sources des données",
"legend": "Légende",
"openLegend": "Ouvrir la légende",
"clearSelectionEsc": "Effacer la sélection (Échap)",
"closeEsc": "Fermer (Échap)",
"close": "Fermer",
"retry": "Réessayer",
"loadingMap": "Chargement de la carte",
"flyToPin": "Aller à l’épingle",
"primaryReligion": "Religion principale",
"ropHierarchy": "Hiérarchie ROP"
},
"pill": {
"religion": "Répartition des religions · cliquez sur une épingle pour le détail",
"ropTree": "Hiérarchie ROP · bloc d’affinité → grappe → peuple → PGIC",
"languageFamilies": "Familles ethnolinguistiques (PPLR) · cliquez sur une épingle pour le détail",
"sunburst": "Hiérarchie ROP · bloc d’affinité → grappe de peuples · faites défiler pour zoomer",
"pins": "Toutes les épingles peuple-pays · cliquez pour les identifiants des systèmes",
"coverage": "Couverture de traduction · teinte = moyenne des langues renseignées par pays · cliquez sur une épingle ou un pays",
"rosetta": "Rosetta des identifiants · couleur = identifiants présents · ★ = ligne d’exemple"
},
"legendTitle": {
"avgLocales": "Moyenne des langues / {n}",
"idCoverage": "Couverture des identifiants"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, les trois",
"imbOnly": "IMB seulement",
"jpOnly": "JP seulement",
"neither": "aucun des deux"
},
"samplePg": "★ peuple d’exemple",
"missingIds": "Pas encore mis en correspondance dans PPLR : {ids}. Une ligne vide est une lacune des données, pas une erreur."
},
"popup": {
"noName": "(sans nom)",
"country": "Pays",
"religion": "Religion",
"language": "Langue",
"family": "Famille",
"affinityBloc": "Bloc d’affinité",
"cluster": "Grappe",
"peopleCluster": "Grappe de peuples",
"peopleGroup": "Peuple",
"pgCount": "Peuples",
"avgLocalesFilled": "Moyenne des langues renseignées",
"iso3Country": "Pays ISO-3"
},
"card": {
"nameInLocales": "Nom dans {n} langues"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "बधिर कमी",
"t_resources": "जनसमूह अनुसार संसाधन",
"t_languages": "देश अनुसार भाषाएँ",
"t_growth": "पहुँच बनाम जनसंख्या वृद्धि"
"t_growth": "पहुँच बनाम जनसंख्या वृद्धि",
"t_bridgereach": "एक भाषा, कितने जनसमूह"
},
"pages": {
"listTitle": "भाषा अनुसार सुसमाचार संसाधन",

View file

@ -113,7 +113,13 @@
"colCountry": "देश",
"colLanguage": "भाषा",
"colPopulation": "जनसंख्या",
"colReligion": "धर्म"
"colReligion": "धर्म",
"colPctChristian": "% ईसाई",
"colAdjacency": "निकटता",
"colCode": "कोड",
"sortMobilization": "संग्रहण",
"sortFrontier": "सीमांत पहले",
"colName": "नाम"
},
"fc": {
"clusterBy": "समूह बनाएँ",
@ -124,7 +130,17 @@
"legendPetal": "पहुँचा पड़ोसी (बारीक, असली निर्देशांक)",
"legendHelp": "फिर से समूह बनाने के लिए ऊपर के घेरे पर क्लिक करें · पंखुड़ियाँ खोलने के लिए केंद्र पर क्लिक करें",
"clustering": "फ़सल को भाषा के अनुसार समूहित किया जा रहा है…",
"signLanguage": "सांकेतिक भाषा"
"signLanguage": "सांकेतिक भाषा",
"mode": {
"family": "परिवार",
"language": "भाषा",
"dialect": "बोली"
},
"size": {
"fewerBigger": "कम · बड़े",
"more": "अधिक",
"most": "सर्वाधिक"
}
},
"mc": {
"greenEncoding": "हरे रंग की कोडिंग",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "नक्शा या फूल दृश्य",
"loading": "फ़सल का खेत लोड हो रहा है…",
"unreachedTarget": "अपहुँचा लक्ष्य"
"unreachedTarget": "अपहुँचा लक्ष्य",
"map": "मानचित्र",
"flower": "फूल",
"details": "विवरण",
"senders": "भेजने वाले →",
"clustersHidden": "क्लस्टर · {n} छिपे",
"legendSend": "SEND (ठोस = वही भाषा · डैश = क्लस्टर · बिंदु = देश के भीतर)",
"legendSelection": "चयन · त्रिज्या = जनसंख्या का लघुगणक · लूका 10:2",
"navySend": "गहरा नीला = SEND",
"goldMesh": "सुनहरा = एक-भाषा जाल"
},
"flower": {
"pickTarget": "नक्शे पर एक लाल लक्ष्य चुनें —",

View file

@ -32,5 +32,61 @@
"readyPins": "तैयार · पिन: {pins} · किसी पिन पर क्लिक करें",
"readyLanguage": "तैयार · पिन: {pins} · परिवार कवरेज {coverage}% · शीर्ष 25 = {top}%",
"readyReligion": "पिन: {pins} · सबसे आम: {religion} ({count})"
},
"ui": {
"chooseView": "दृश्य चुनें",
"search": "जनसमूह, स्थान, भाषा, धर्म से खोजें…",
"baseMap": "आधार मानचित्र",
"dataSources": "डेटा स्रोत",
"legend": "सूचकांक",
"openLegend": "सूचकांक खोलें",
"clearSelectionEsc": "चयन हटाएँ (Esc)",
"closeEsc": "बंद करें (Esc)",
"close": "बंद करें",
"retry": "फिर से कोशिश करें",
"loadingMap": "मानचित्र लोड हो रहा है",
"flyToPin": "पिन पर जाएँ",
"primaryReligion": "प्रमुख धर्म",
"ropHierarchy": "ROP पदानुक्रम"
},
"pill": {
"religion": "धर्मों का वितरण · विवरण के लिए किसी पिन पर क्लिक करें",
"ropTree": "ROP पदानुक्रम · सम्बद्धता खंड → क्लस्टर → जनसमूह → PGIC",
"languageFamilies": "नृजातीय-भाषाई परिवार (PPLR) · विवरण के लिए किसी पिन पर क्लिक करें",
"sunburst": "ROP पदानुक्रम · सम्बद्धता खंड → जनसमूह क्लस्टर · ज़ूम के लिए स्क्रॉल करें",
"pins": "देश-वार सभी जनसमूह पिन · प्रणालियों की पहचान-संख्याओं के लिए क्लिक करें",
"coverage": "अनुवाद कवरेज · रंग = प्रति देश भरी गई भाषाओं का औसत · किसी पिन या देश पर क्लिक करें",
"rosetta": "पहचान-संख्या रोसेटा · रंग = कौन-सी पहचान-संख्याएँ हैं · ★ = नमूना पंक्ति"
},
"legendTitle": {
"avgLocales": "भाषाओं का औसत / {n}",
"idCoverage": "पहचान-संख्या कवरेज"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, तीनों",
"imbOnly": "केवल IMB",
"jpOnly": "केवल JP",
"neither": "दोनों में से कोई नहीं"
},
"samplePg": "★ नमूना जनसमूह",
"missingIds": "PPLR में अभी मिलान नहीं हुआ: {ids}। खाली पंक्ति डेटा की कमी है, त्रुटि नहीं।"
},
"popup": {
"noName": "(कोई नाम नहीं)",
"country": "देश",
"religion": "धर्म",
"language": "भाषा",
"family": "परिवार",
"affinityBloc": "सम्बद्धता खंड",
"cluster": "क्लस्टर",
"peopleCluster": "जनसमूह क्लस्टर",
"peopleGroup": "जनसमूह",
"pgCount": "जनसमूह",
"avgLocalesFilled": "भरी गई भाषाओं का औसत",
"iso3Country": "ISO-3 देश"
},
"card": {
"nameInLocales": "{n} भाषाओं में नाम"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "Il divario sordo",
"t_resources": "Risorse per popolo",
"t_languages": "Lingue per paese",
"t_growth": "Accesso vs crescita"
"t_growth": "Accesso vs crescita",
"t_bridgereach": "Una lingua, quanti popoli"
},
"pages": {
"listTitle": "Risorse del Vangelo per lingua",

View file

@ -113,7 +113,13 @@
"colCountry": "Paese",
"colLanguage": "Lingua",
"colPopulation": "Popolazione",
"colReligion": "Religione"
"colReligion": "Religione",
"colPctChristian": "% cristiani",
"colAdjacency": "Adiacenza",
"colCode": "Codice",
"sortMobilization": "Mobilitazione",
"sortFrontier": "Prima le frontiere",
"colName": "Nome"
},
"fc": {
"clusterBy": "Raggruppa per",
@ -124,7 +130,17 @@
"legendPetal": "Vicino raggiunto (dettagliato, coordinate reali)",
"legendHelp": "clicca un cerchio in alto per raggruppare · clicca un nucleo per aprire i petali",
"clustering": "Raggruppamento del raccolto per lingua…",
"signLanguage": "Lingua dei segni"
"signLanguage": "Lingua dei segni",
"mode": {
"family": "Famiglia",
"language": "Lingua",
"dialect": "Dialetto"
},
"size": {
"fewerBigger": "meno · più grandi",
"more": "di più",
"most": "il massimo"
}
},
"mc": {
"greenEncoding": "Codifica del colore verde",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Vista mappa o fiore",
"loading": "Caricamento del campo della mietitura…",
"unreachedTarget": "obiettivo non raggiunto"
"unreachedTarget": "obiettivo non raggiunto",
"map": "Mappa",
"flower": "Fiore",
"details": "Dettagli",
"senders": "mittenti →",
"clustersHidden": "cluster · {n} nascosti",
"legendSend": "SEND (continua = stessa lingua · tratteggiata = cluster · punteggiata = nel paese)",
"legendSelection": "selezione · raggio = log della popolazione · Luca 10,2",
"navySend": "blu scuro = SEND",
"goldMesh": "oro = rete della stessa lingua"
},
"flower": {
"pickTarget": "Scegli un obiettivo rosso sulla mappa —",

View file

@ -32,5 +32,61 @@
"readyPins": "Pronto · segnaposto: {pins} · clicca un segnaposto",
"readyLanguage": "Pronto · segnaposto: {pins} · copertura famiglie {coverage}% · top 25 = {top}%",
"readyReligion": "Segnaposto: {pins} · più comune: {religion} ({count})"
},
"ui": {
"chooseView": "Scegli una vista",
"search": "Cerca per popolo, luogo, lingua, religione…",
"baseMap": "Mappa di base",
"dataSources": "Fonti dei dati",
"legend": "Legenda",
"openLegend": "Apri la legenda",
"clearSelectionEsc": "Cancella la selezione (Esc)",
"closeEsc": "Chiudi (Esc)",
"close": "Chiudi",
"retry": "Riprova",
"loadingMap": "Caricamento della mappa",
"flyToPin": "Vai al segnaposto",
"primaryReligion": "Religione principale",
"ropHierarchy": "Gerarchia ROP"
},
"pill": {
"religion": "Distribuzione delle religioni · clicca un segnaposto per i dettagli",
"ropTree": "Gerarchia ROP · blocco di affinità → cluster → popolo → PGIC",
"languageFamilies": "Famiglie etnolinguistiche (PPLR) · clicca un segnaposto per i dettagli",
"sunburst": "Gerarchia ROP · blocco di affinità → cluster di popoli · scorri per ingrandire",
"pins": "Tutti i segnaposto popolo-paese · clicca per gli identificativi dei sistemi",
"coverage": "Copertura delle traduzioni · colore = media delle lingue compilate per paese · clicca un segnaposto o un paese",
"rosetta": "Rosetta degli identificativi · colore = identificativi presenti · ★ = riga di esempio"
},
"legendTitle": {
"avgLocales": "Media delle lingue / {n}",
"idCoverage": "Copertura degli identificativi"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, tutti e tre",
"imbOnly": "Solo IMB",
"jpOnly": "Solo JP",
"neither": "nessuno dei due"
},
"samplePg": "★ popolo di esempio",
"missingIds": "Non ancora collegati in PPLR: {ids}. Una riga vuota è una lacuna nei dati, non un errore."
},
"popup": {
"noName": "(senza nome)",
"country": "Paese",
"religion": "Religione",
"language": "Lingua",
"family": "Famiglia",
"affinityBloc": "Blocco di affinità",
"cluster": "Cluster",
"peopleCluster": "Cluster di popoli",
"peopleGroup": "Popolo",
"pgCount": "Popoli",
"avgLocalesFilled": "Media delle lingue compilate",
"iso3Country": "Paese ISO-3"
},
"card": {
"nameInLocales": "Nome in {n} lingue"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "A lacuna surda",
"t_resources": "Recursos por povo",
"t_languages": "Idiomas por país",
"t_growth": "Acesso vs crescimento"
"t_growth": "Acesso vs crescimento",
"t_bridgereach": "Uma língua, quantos povos"
},
"pages": {
"listTitle": "Recursos do evangelho por idioma",

View file

@ -113,7 +113,13 @@
"colCountry": "País",
"colLanguage": "Idioma",
"colPopulation": "População",
"colReligion": "Religião"
"colReligion": "Religião",
"colPctChristian": "% cristãos",
"colAdjacency": "Adjacência",
"colCode": "Código",
"sortMobilization": "Mobilização",
"sortFrontier": "Fronteira primeiro",
"colName": "Nome"
},
"fc": {
"clusterBy": "Agrupar por",
@ -124,7 +130,17 @@
"legendPetal": "Vizinho alcançado (detalhado, coordenadas reais)",
"legendHelp": "clique num círculo superior para reagrupar · clique num núcleo para abrir as pétalas",
"clustering": "A agrupar a colheita por idioma…",
"signLanguage": "Língua de sinais"
"signLanguage": "Língua de sinais",
"mode": {
"family": "Família",
"language": "Idioma",
"dialect": "Dialeto"
},
"size": {
"fewerBigger": "menos · maiores",
"more": "mais",
"most": "o máximo"
}
},
"mc": {
"greenEncoding": "Codificação da cor verde",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Visão de mapa ou de flor",
"loading": "Carregando o campo da colheita…",
"unreachedTarget": "alvo não alcançado"
"unreachedTarget": "alvo não alcançado",
"map": "Mapa",
"flower": "Flor",
"details": "Detalhes",
"senders": "enviadores →",
"clustersHidden": "agrupamentos · {n} ocultos",
"legendSend": "SEND (contínua = mesmo idioma · tracejada = agrupamento · pontilhada = no país)",
"legendSelection": "seleção · raio = log da população · Lucas 10:2",
"navySend": "azul-marinho = SEND",
"goldMesh": "dourado = rede do mesmo idioma"
},
"flower": {
"pickTarget": "Escolha um alvo vermelho no mapa —",

View file

@ -32,5 +32,61 @@
"readyPins": "Pronto · marcadores: {pins} · clique num marcador",
"readyLanguage": "Pronto · marcadores: {pins} · cobertura de famílias {coverage}% · top 25 = {top}%",
"readyReligion": "Marcadores: {pins} · mais comum: {religion} ({count})"
},
"ui": {
"chooseView": "Escolha uma vista",
"search": "Pesquisar por povo, lugar, idioma, religião…",
"baseMap": "Mapa base",
"dataSources": "Fontes de dados",
"legend": "Legenda",
"openLegend": "Abrir a legenda",
"clearSelectionEsc": "Limpar a seleção (Esc)",
"closeEsc": "Fechar (Esc)",
"close": "Fechar",
"retry": "Tentar novamente",
"loadingMap": "A carregar o mapa",
"flyToPin": "Ir para o marcador",
"primaryReligion": "Religião principal",
"ropHierarchy": "Hierarquia ROP"
},
"pill": {
"religion": "Distribuição das religiões · clique num marcador para ver detalhes",
"ropTree": "Hierarquia ROP · bloco de afinidade → agrupamento → povo → PGIC",
"languageFamilies": "Famílias etnolinguísticas (PPLR) · clique num marcador para ver detalhes",
"sunburst": "Hierarquia ROP · bloco de afinidade → agrupamento de povos · role para ampliar",
"pins": "Todos os marcadores povo-país · clique para ver os identificadores dos sistemas",
"coverage": "Cobertura de tradução · cor = média de idiomas preenchidos por país · clique num marcador ou num país",
"rosetta": "Rosetta de identificadores · cor = identificadores existentes · ★ = linha de exemplo"
},
"legendTitle": {
"avgLocales": "Média de idiomas / {n}",
"idCoverage": "Cobertura de identificadores"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, os três",
"imbOnly": "Apenas IMB",
"jpOnly": "Apenas JP",
"neither": "nenhum"
},
"samplePg": "★ povo de exemplo",
"missingIds": "Ainda não correspondidos no PPLR: {ids}. Uma linha vazia é uma lacuna nos dados, não um erro."
},
"popup": {
"noName": "(sem nome)",
"country": "País",
"religion": "Religião",
"language": "Idioma",
"family": "Família",
"affinityBloc": "Bloco de afinidade",
"cluster": "Agrupamento",
"peopleCluster": "Agrupamento de povos",
"peopleGroup": "Povo",
"pgCount": "Povos",
"avgLocalesFilled": "Média de idiomas preenchidos",
"iso3Country": "País ISO-3"
},
"card": {
"nameInLocales": "Nome em {n} idiomas"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "Golul surd",
"t_resources": "Resurse pe popoare",
"t_languages": "Limbi pe țări",
"t_growth": "Acces vs creștere"
"t_growth": "Acces vs creștere",
"t_bridgereach": "O limbă, câte popoare"
},
"pages": {
"listTitle": "Resurse ale Evangheliei pe limbi",

View file

@ -113,7 +113,13 @@
"colCountry": "Țară",
"colLanguage": "Limbă",
"colPopulation": "Populație",
"colReligion": "Religie"
"colReligion": "Religie",
"colPctChristian": "% creștini",
"colAdjacency": "Vecinătate",
"colCode": "Cod",
"sortMobilization": "Mobilizare",
"sortFrontier": "Frontiera întâi",
"colName": "Nume"
},
"fc": {
"clusterBy": "Grupați după",
@ -124,7 +130,17 @@
"legendPetal": "Vecin atins (detaliat, coordonate reale)",
"legendHelp": "clic pe un cerc de sus pentru regrupare · clic pe un nucleu pentru a-i deschide petalele",
"clustering": "Se grupează recolta după limbă…",
"signLanguage": "Limbajul semnelor"
"signLanguage": "Limbajul semnelor",
"mode": {
"family": "Familie",
"language": "Limbă",
"dialect": "Dialect"
},
"size": {
"fewerBigger": "mai puține · mai mari",
"more": "mai multe",
"most": "cele mai multe"
}
},
"mc": {
"greenEncoding": "Codificarea culorii verzi",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Vedere hartă sau floare",
"loading": "Se încarcă câmpul secerișului…",
"unreachedTarget": "țintă neatinsă"
"unreachedTarget": "țintă neatinsă",
"map": "Hartă",
"flower": "Floare",
"details": "Detalii",
"senders": "trimițători →",
"clustersHidden": "grupuri · {n} ascunse",
"legendSend": "SEND (continuă = aceeași limbă · întreruptă = grup · punctată = în țară)",
"legendSelection": "selecție · rază = log al populației · Luca 10:2",
"navySend": "bleumarin = SEND",
"goldMesh": "auriu = rețea de aceeași limbă"
},
"flower": {
"pickTarget": "Alege o țintă roșie pe hartă —",

View file

@ -32,5 +32,61 @@
"readyPins": "Gata · repere: {pins} · dați clic pe un reper",
"readyLanguage": "Gata · repere: {pins} · acoperirea familiilor {coverage}% · top 25 = {top}%",
"readyReligion": "Repere: {pins} · cea mai frecventă: {religion} ({count})"
},
"ui": {
"chooseView": "Alegeți o vedere",
"search": "Căutați după popor, loc, limbă, religie…",
"baseMap": "Hartă de bază",
"dataSources": "Surse de date",
"legend": "Legendă",
"openLegend": "Deschide legenda",
"clearSelectionEsc": "Șterge selecția (Esc)",
"closeEsc": "Închide (Esc)",
"close": "Închide",
"retry": "Reîncercați",
"loadingMap": "Se încarcă harta",
"flyToPin": "Mergi la reper",
"primaryReligion": "Religia principală",
"ropHierarchy": "Ierarhia ROP"
},
"pill": {
"religion": "Distribuția religiilor · dați clic pe un reper pentru detalii",
"ropTree": "Ierarhia ROP · bloc de afinitate → grup → popor → PGIC",
"languageFamilies": "Familii etnolingvistice (PPLR) · dați clic pe un reper pentru detalii",
"sunburst": "Ierarhia ROP · bloc de afinitate → grup de popoare · derulați pentru zoom",
"pins": "Toate reperele popor-țară · dați clic pentru identificatorii sistemelor",
"coverage": "Acoperirea traducerilor · nuanța = media limbilor completate pe țară · dați clic pe un reper sau pe o țară",
"rosetta": "Rosetta identificatorilor · culoare = identificatorii existenți · ★ = rând de exemplu"
},
"legendTitle": {
"avgLocales": "Media limbilor / {n}",
"idCoverage": "Acoperirea identificatorilor"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, toate trei",
"imbOnly": "Doar IMB",
"jpOnly": "Doar JP",
"neither": "niciunul"
},
"samplePg": "★ popor de exemplu",
"missingIds": "Încă neasociate în PPLR: {ids}. Un rând gol este o lipsă de date, nu o eroare."
},
"popup": {
"noName": "(fără nume)",
"country": "Țară",
"religion": "Religie",
"language": "Limbă",
"family": "Familie",
"affinityBloc": "Bloc de afinitate",
"cluster": "Grup",
"peopleCluster": "Grup de popoare",
"peopleGroup": "Popor",
"pgCount": "Popoare",
"avgLocalesFilled": "Media limbilor completate",
"iso3Country": "Țară ISO-3"
},
"card": {
"nameInLocales": "Numele în {n} limbi"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "Пробел у глухих",
"t_resources": "Ресурсы по народам",
"t_languages": "Языки по странам",
"t_growth": "Доступ и рост населения"
"t_growth": "Доступ и рост населения",
"t_bridgereach": "Один язык — сколько народов"
},
"pages": {
"listTitle": "Ресурсы Евангелия по языкам",

View file

@ -113,7 +113,13 @@
"colCountry": "Страна",
"colLanguage": "Язык",
"colPopulation": "Численность",
"colReligion": "Религия"
"colReligion": "Религия",
"colPctChristian": "% христиан",
"colAdjacency": "Соседство",
"colCode": "Код",
"sortMobilization": "Мобилизация",
"sortFrontier": "Сначала рубежи",
"colName": "Название"
},
"fc": {
"clusterBy": "Группировать по",
@ -124,7 +130,17 @@
"legendPetal": "Охваченный сосед (детально, реальные координаты)",
"legendHelp": "нажмите верхний круг, чтобы перегруппировать · нажмите ядро, чтобы раскрыть лепестки",
"clustering": "Группировка жатвы по языку…",
"signLanguage": "Жестовый язык"
"signLanguage": "Жестовый язык",
"mode": {
"family": "Семья",
"language": "Язык",
"dialect": "Диалект"
},
"size": {
"fewerBigger": "меньше · крупнее",
"more": "больше",
"most": "больше всего"
}
},
"mc": {
"greenEncoding": "Кодирование зелёного цвета",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Вид карты или цветка",
"loading": "Загрузка поля жатвы…",
"unreachedTarget": "недостигнутая цель"
"unreachedTarget": "недостигнутая цель",
"map": "Карта",
"flower": "Цветок",
"details": "Подробности",
"senders": "отправителей →",
"clustersHidden": "кластеров · скрыто: {n}",
"legendSend": "SEND (сплошная = тот же язык · пунктир = кластер · точки = внутри страны)",
"legendSelection": "выбор · радиус = логарифм населения · Луки 10:2",
"navySend": "тёмно-синий = SEND",
"goldMesh": "золотой = сеть одного языка"
},
"flower": {
"pickTarget": "Выберите красную цель на карте —",

View file

@ -32,5 +32,61 @@
"readyPins": "Готово · меток: {pins} · нажмите на метку",
"readyLanguage": "Готово · меток: {pins} · покрытие семей {coverage}% · топ-25 = {top}%",
"readyReligion": "Меток: {pins} · чаще всего: {religion} ({count})"
},
"ui": {
"chooseView": "Выберите вид",
"search": "Поиск по народу, месту, языку, религии…",
"baseMap": "Базовая карта",
"dataSources": "Источники данных",
"legend": "Легенда",
"openLegend": "Открыть легенду",
"clearSelectionEsc": "Снять выделение (Esc)",
"closeEsc": "Закрыть (Esc)",
"close": "Закрыть",
"retry": "Повторить",
"loadingMap": "Загрузка карты",
"flyToPin": "Перейти к метке",
"primaryReligion": "Основная религия",
"ropHierarchy": "Иерархия ROP"
},
"pill": {
"religion": "Распределение религий · нажмите на метку, чтобы узнать подробности",
"ropTree": "Иерархия ROP · блок родства → кластер → народ → PGIC",
"languageFamilies": "Этнолингвистические семьи (PPLR) · нажмите на метку, чтобы узнать подробности",
"sunburst": "Иерархия ROP · блок родства → кластер народов · прокрутите для масштаба",
"pins": "Все метки «народ в стране» · нажмите, чтобы увидеть идентификаторы систем",
"coverage": "Покрытие переводов · заливка = среднее число заполненных языков по стране · нажмите на метку или страну",
"rosetta": "Розетта идентификаторов · цвет = какие идентификаторы есть · ★ = пример"
},
"legendTitle": {
"avgLocales": "Среднее число языков / {n}",
"idCoverage": "Покрытие идентификаторов"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, все три",
"imbOnly": "Только IMB",
"jpOnly": "Только JP",
"neither": "ни того, ни другого"
},
"samplePg": "★ пример народа",
"missingIds": "Ещё не сопоставлено в PPLR: {ids}. Пустая строка — пробел в данных, а не ошибка."
},
"popup": {
"noName": "(без названия)",
"country": "Страна",
"religion": "Религия",
"language": "Язык",
"family": "Семья",
"affinityBloc": "Блок родства",
"cluster": "Кластер",
"peopleCluster": "Кластер народов",
"peopleGroup": "Народ",
"pgCount": "Народы",
"avgLocalesFilled": "Среднее число заполненных языков",
"iso3Country": "Страна ISO-3"
},
"card": {
"nameInLocales": "Название на {n} языках"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "聋人缺口",
"t_resources": "按族群列出资源",
"t_languages": "各国语言",
"t_growth": "可及性与人口增长"
"t_growth": "可及性与人口增长",
"t_bridgereach": "一种语言,覆盖多少民族"
},
"pages": {
"listTitle": "按语言列出的福音资源",

View file

@ -113,7 +113,13 @@
"colCountry": "国家",
"colLanguage": "语言",
"colPopulation": "人口",
"colReligion": "宗教"
"colReligion": "宗教",
"colPctChristian": "基督徒 %",
"colAdjacency": "邻近关系",
"colCode": "代码",
"sortMobilization": "动员",
"sortFrontier": "前沿优先",
"colName": "名称"
},
"fc": {
"clusterBy": "分组依据",
@ -124,7 +130,17 @@
"legendPetal": "已得邻居(细分,真实坐标)",
"legendHelp": "点击上方圆圈重新分组 · 点击核心展开花瓣",
"clustering": "正在按语言整理庄稼…",
"signLanguage": "手语"
"signLanguage": "手语",
"mode": {
"family": "语系",
"language": "语言",
"dialect": "方言"
},
"size": {
"fewerBigger": "更少 · 更大",
"more": "更多",
"most": "最多"
}
},
"mc": {
"greenEncoding": "绿色编码",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "地图或花朵视图",
"loading": "正在加载庄稼之田…",
"unreachedTarget": "未得目标"
"unreachedTarget": "未得目标",
"map": "地图",
"flower": "花朵",
"details": "详情",
"senders": "派遣方 →",
"clustersHidden": "个民族群 · 隐藏 {n} 个",
"legendSend": "SEND(实线 = 同语言 · 虚线 = 民族群 · 点线 = 国内)",
"legendSelection": "选中 · 半径 = 人口对数 · 路加福音 10:2",
"navySend": "深蓝 = SEND",
"goldMesh": "金色 = 同语言网络"
},
"flower": {
"pickTarget": "在地图上选择一个红色目标 —",

View file

@ -32,5 +32,61 @@
"readyPins": "就绪 · 族群标记:{pins} · 点击任一标记",
"readyLanguage": "就绪 · 标记:{pins} · 语系覆盖率 {coverage}% · 前 25 = {top}%",
"readyReligion": "标记:{pins} · 最常见:{religion}({count})"
},
"ui": {
"chooseView": "选择视图",
"search": "按族群、地点、语言、宗教搜索…",
"baseMap": "底图",
"dataSources": "数据来源",
"legend": "图例",
"openLegend": "打开图例",
"clearSelectionEsc": "清除选择 (Esc)",
"closeEsc": "关闭 (Esc)",
"close": "关闭",
"retry": "重试",
"loadingMap": "正在加载地图",
"flyToPin": "飞到标记",
"primaryReligion": "主要宗教",
"ropHierarchy": "ROP 层级"
},
"pill": {
"religion": "宗教分布 · 点击标记查看详情",
"ropTree": "ROP 层级 · 亲缘区块 → 民族群 → 族群 → PGIC",
"languageFamilies": "民族语言语系 (PPLR) · 点击标记查看详情",
"sunburst": "ROP 层级 · 亲缘区块 → 民族群 · 滚动缩放",
"pins": "全部国家内族群标记 · 点击查看跨系统 ID",
"coverage": "翻译覆盖率 · 着色 = 各国平均已填语种数 · 点击标记或国家",
"rosetta": "跨系统 ID 对照 · 颜色 = 已有哪些 ID · ★ = 示例行"
},
"legendTitle": {
"avgLocales": "平均语种数 / {n}",
"idCoverage": "ID 覆盖"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD 三者俱全",
"imbOnly": "仅 IMB",
"jpOnly": "仅 JP",
"neither": "两者皆无"
},
"samplePg": "★ 示例族群",
"missingIds": "PPLR 尚未对应:{ids}。空行是数据缺口,不是错误。"
},
"popup": {
"noName": "(无名称)",
"country": "国家",
"religion": "宗教",
"language": "语言",
"family": "语系",
"affinityBloc": "亲缘区块",
"cluster": "民族群",
"peopleCluster": "民族群",
"peopleGroup": "族群",
"pgCount": "族群数",
"avgLocalesFilled": "平均已填语种数",
"iso3Country": "ISO-3 国家"
},
"card": {
"nameInLocales": "{n} 种语言中的名称"
}
}

View file

@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* build-web-boundaries.mjs — the INTERACTIVE-map copies of the boundary files: small enough to parse
* fast, with every feature, every island and every property kept.
*
* node library/scripts/boundaries/build-web-boundaries.mjs
*
* WHY. rega measured ~3.6 MB of boundary GeoJSON (world-adm0 1.9 MB, gb-IND-ADM1 1.6 MB) costing
* 5–6 s of MapLibre worker parsing before the first territory draws, on every page of every
* partner site. A web map drawn at zoom 1–7 needs a fraction of those vertices. The FULL files stay
* as they are: posters and print use them, and so does every build-time point-in-polygon.
*
* HOW (mapshaper, pinned below; fetched with npx, it is not a repo dependency):
* -explode split each multi-part feature into its parts, so every island is its own shape
* -simplify visvalingam weighted percentage=P keep-shapes
* topology-preserving (shared borders stay shared); keep-shapes stops any shape
* from vanishing, which, after the explode, means no island is ever dropped
* (without the explode, keep-shapes guards only whole features: 572 parts -> 240)
* -dissolve KEY copy-fields=... put each feature back together, same properties
* precision=0.0001 ~11 m, far below a pixel at the zooms these files are drawn at
*
* VALIDATED, the run FAILS otherwise: the same feature count, the same keys, the same polygon-part
* count (no dropped island), and the same property names and values on every feature.
* The measured report (size, parts, area error, point-in-polygon drift) is in
* _qa-cycle/03-deployment-cycle/REPORT-WEB-BOUNDARIES.md.
*/
import { execFileSync } from 'node:child_process'
import { readFileSync, statSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const MAPSHAPER = 'mapshaper@0.6.121'
const UPG = 'plugin-designs/my-upg-100-list/data'
const JOBS = [
// src, out, dissolve key, the other properties, simplification percentage
{ src: `${UPG}/adm0/world-adm0.geojson`, out: `${UPG}/adm0/world-adm0-web.geojson`, key: 'iso3', copy: ['shapeGroup', 'shapeName'], pct: '8%' },
{ src: 'data/boundaries/gb-IND-ADM1-full.geojson', out: 'data/boundaries/gb-IND-ADM1-full-web.geojson', key: 'shapeName', copy: ['iso3', 'shapeGroup', 'shapeISO'], pct: '10%' },
// UPG-100's curated partition: `listId` is load-bearing (zone-geometry.js), so it must survive.
{ src: `${UPG}/gb-IND-ADM1.geojson`, out: `${UPG}/gb-IND-ADM1-web.geojson`, key: 'shapeName', copy: ['iso3', 'shapeGroup', 'listId'], pct: '10%' },
// gb-CHN-ADM1.geojson is 56 KB (12 provinces): already web-sized, no copy made.
]
const fail = (m) => { console.error('build-web-boundaries: FAIL — ' + m); process.exit(1) }
const parts = (fc) => fc.features.reduce((t, f) => t + (f.geometry.type === 'Polygon' ? 1 : f.geometry.coordinates.length), 0)
for (const j of JOBS) {
const src = join(ROOT, j.src), out = join(ROOT, j.out)
execFileSync('npx', ['--yes', MAPSHAPER, src, '-explode',
'-simplify', 'visvalingam', 'weighted', `percentage=${j.pct}`, 'keep-shapes',
'-dissolve', j.key, `copy-fields=${j.copy.join(',')}`,
'-o', out, 'format=geojson', 'precision=0.0001'], { stdio: ['ignore', 'ignore', 'pipe'] })
const a = JSON.parse(readFileSync(src, 'utf8')), b = JSON.parse(readFileSync(out, 'utf8'))
if (a.features.length !== b.features.length) fail(`${j.out}: ${b.features.length} features, source has ${a.features.length}`)
const byKey = new Map(b.features.map((f) => [f.properties[j.key], f]))
for (const f of a.features) {
const g = byKey.get(f.properties[j.key])
if (!g) fail(`${j.out}: lost ${j.key}=${f.properties[j.key]}`)
for (const [k, v] of Object.entries(f.properties)) if (g.properties[k] !== v) fail(`${j.out}: ${f.properties[j.key]}.${k} is ${JSON.stringify(g.properties[k])}, source ${JSON.stringify(v)}`)
}
if (parts(a) !== parts(b)) fail(`${j.out}: ${parts(b)} polygon parts, source has ${parts(a)} (an island was dropped)`)
const kb = (p) => Math.round(statSync(p).size / 1024)
console.log(`${j.out}: ${kb(src)} KB -> ${kb(out)} KB · ${b.features.length} features · ${parts(b)} parts · properties identical`)
}

View file

@ -182,7 +182,9 @@
"funnel_list_unreached": "Project 7000: unreached people groups",
"funnel_list_frontier": "Project 4700: frontier people groups",
"funnel_list_frontier-500k": "Project 700: frontier people groups of 500,000 or more",
"funnel_list_unengaged": "Unengaged unreached people groups"
"funnel_list_unengaged": "Unengaged unreached people groups",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} more"
},
"es": {
"map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS",
@ -357,7 +359,9 @@
"funnel_list_unreached": "Proyecto 7000: grupos étnicos no alcanzados",
"funnel_list_frontier": "Proyecto 4700: grupos étnicos de frontera",
"funnel_list_frontier-500k": "Proyecto 700: grupos étnicos de frontera de 500.000 o más",
"funnel_list_unengaged": "Grupos étnicos no alcanzados y sin obreros"
"funnel_list_unengaged": "Grupos étnicos no alcanzados y sin obreros",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} más"
},
"fr": {
"map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS",
@ -532,7 +536,9 @@
"funnel_list_unreached": "Projet 7000 : peuples non atteints",
"funnel_list_frontier": "Projet 4700 : peuples frontières",
"funnel_list_frontier-500k": "Projet 700 : peuples frontières de 500 000 personnes ou plus",
"funnel_list_unengaged": "Peuples non atteints et sans ouvrier"
"funnel_list_unengaged": "Peuples non atteints et sans ouvrier",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} autres"
},
"pt": {
"map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS",
@ -707,7 +713,9 @@
"funnel_list_unreached": "Projeto 7000: grupos étnicos não alcançados",
"funnel_list_frontier": "Projeto 4700: grupos étnicos de fronteira",
"funnel_list_frontier-500k": "Projeto 700: grupos étnicos de fronteira de 500.000 ou mais",
"funnel_list_unengaged": "Grupos étnicos não alcançados e sem obreiros"
"funnel_list_unengaged": "Grupos étnicos não alcançados e sem obreiros",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} mais"
},
"de": {
"map_page_kicker": "UNERREICHTE VOLKSGRUPPEN",
@ -882,7 +890,9 @@
"funnel_list_unreached": "Projekt 7000: unerreichte Volksgruppen",
"funnel_list_frontier": "Projekt 4700: Frontier-Volksgruppen",
"funnel_list_frontier-500k": "Projekt 700: Frontier-Volksgruppen mit 500.000 oder mehr",
"funnel_list_unengaged": "Unerreichte Volksgruppen ohne Mitarbeiter"
"funnel_list_unengaged": "Unerreichte Volksgruppen ohne Mitarbeiter",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} weitere"
},
"it": {
"map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI",
@ -1057,7 +1067,9 @@
"funnel_list_unreached": "Progetto 7000: gruppi etnici non raggiunti",
"funnel_list_frontier": "Progetto 4700: gruppi etnici di frontiera",
"funnel_list_frontier-500k": "Progetto 700: gruppi etnici di frontiera di 500.000 o più",
"funnel_list_unengaged": "Gruppi etnici non raggiunti e senza operai"
"funnel_list_unengaged": "Gruppi etnici non raggiunti e senza operai",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + altri {n}"
},
"ro": {
"map_page_kicker": "GRUPURI ETNICE NEATINSE",
@ -1232,7 +1244,9 @@
"funnel_list_unreached": "Proiectul 7000: grupuri etnice neatinse",
"funnel_list_frontier": "Proiectul 4700: grupuri etnice de frontieră",
"funnel_list_frontier-500k": "Proiectul 700: grupuri etnice de frontieră de 500.000 sau mai multe",
"funnel_list_unengaged": "Grupuri etnice neatinse și fără lucrători"
"funnel_list_unengaged": "Grupuri etnice neatinse și fără lucrători",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + încă {n}"
},
"ru": {
"map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ",
@ -1407,7 +1421,9 @@
"funnel_list_unreached": "Проект 7000: неохваченные народы",
"funnel_list_frontier": "Проект 4700: народы-фронтир",
"funnel_list_frontier-500k": "Проект 700: народы-фронтир численностью 500 000 и более",
"funnel_list_unengaged": "Неохваченные народы без работников"
"funnel_list_unengaged": "Неохваченные народы без работников",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + ещё {n}"
},
"ar": {
"map_page_kicker": "شعوب غير مبلَّغة",
@ -1582,7 +1598,9 @@
"funnel_list_unreached": "مشروع 7000: المجموعات الشعبية غير المبلَّغة",
"funnel_list_frontier": "مشروع 4700: المجموعات الشعبية الحدودية",
"funnel_list_frontier-500k": "مشروع 700: المجموعات الشعبية الحدودية التي يبلغ عددها 500,000 أو أكثر",
"funnel_list_unengaged": "مجموعات شعبية غير مبلَّغة وبلا عاملين"
"funnel_list_unengaged": "مجموعات شعبية غير مبلَّغة وبلا عاملين",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} أخرى"
},
"hi": {
"map_page_kicker": "अनछुए जनसमूह",
@ -1757,7 +1775,9 @@
"funnel_list_unreached": "परियोजना 7000: अप्राप्त जनसमूह",
"funnel_list_frontier": "परियोजना 4700: सीमांत जनसमूह",
"funnel_list_frontier-500k": "परियोजना 700: 500,000 या अधिक के सीमांत जनसमूह",
"funnel_list_unengaged": "अप्राप्त जनसमूह जिनमें कोई कार्यकर्ता नहीं"
"funnel_list_unengaged": "अप्राप्त जनसमूह जिनमें कोई कार्यकर्ता नहीं",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} और"
},
"zh": {
"map_page_kicker": "未得之民",
@ -1932,6 +1952,8 @@
"funnel_list_unreached": "7000 计划:未得之民族群",
"funnel_list_frontier": "4700 计划:前沿族群",
"funnel_list_frontier-500k": "700 计划:50 万人以上的前沿族群",
"funnel_list_unengaged": "无人服事的未得之民族群"
"funnel_list_unengaged": "无人服事的未得之民族群",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + 另外 {n} 个"
}
}
}

View file

@ -0,0 +1,26 @@
// app-kind.mjs — what KIND of communication channel an App Store app is, from its name.
//
// A NAME-AND-KEYWORD HEURISTIC over a known list, recorded as such in every table that uses it.
// It lives in its own file so the fetcher (fetch-top-apps.mjs) and the table builder
// (build-tabulator-tables.mjs) classify with the SAME rule: the builder re-derives the kind from
// the stored name, so a cached fetch made with an older rule cannot carry an old answer forward.
//
// ★ A BRAND THAT IS ALSO AN ENGLISH WORD MUST BE ANCHORED. `line` matched as a bare word made
// "LINE MAN: Food Delivery & more" (Thailand) and "Malé Taxi Line" (Maldives) each country's TOP
// messaging app, ahead of WhatsApp. LINE's own app is "LINE" or "LINE: Calls & Messages", so it
// matches only as the whole name or the name before a colon.
export const KIND = [
[/\b(whatsapp|telegram|signal|viber|imo|zalo|wechat|weixin|kakaotalk|botim|imessage|messenger|skype|hike|jio ?chat|soul|beetalk)\b|^line(?:$|\s*:)/i, 'messaging'],
[/\b(tiktok|douyin|kwai|likee|snack ?video|moj|josh|triller|reels)\b/i, 'short-video'],
[/\b(facebook|instagram|snapchat|x|twitter|threads|vk|odnoklassniki|weibo|qzone|xiaohongshu|little red|sharechat|koo|mastodon|bluesky)\b/i, 'social'],
[/\b(youtube|bigo|nimo|twitch|rumble|dailymotion|vimeo)\b/i, 'video'],
[/\b(opera|uc ?browser|chrome|firefox|brave|puffin|phoenix ?browser)\b/i, 'browser'],
];
export function classify(name) {
for (const [re, k] of KIND) if (re.test(String(name || '').trim())) return k;
return 'other';
}
// The kinds a reachability table keeps (everything else is not a communication channel).
export const isComms = (kind) => kind !== 'other' && kind !== 'browser';

View file

@ -28,6 +28,7 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { classify, isComms } from './app-kind.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = join(__dirname, 'out');
@ -553,6 +554,15 @@ if (has('social-platforms-by-country.json')) {
}
}
// The headline counts are COUNTED from the rows, never typed: a typed "54" stayed true only until
// the classifier changed. WhatsApp is its two apps (Messenger + Business) together.
function messagingWhy(rows) {
const top = new Map();
for (const r of rows) if (r.is_top_messaging) top.set(r.iso3, r.app);
const count = (re) => [...top.values()].filter((a) => re.test(a)).length;
return `Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp (Messenger or Business) is the top messaging app in ${count(/whatsapp/i)} countries, Telegram in ${count(/telegram/i)} — neither appears in referral share at all.`;
}
// ═══ TABLE 7 — MESSAGING & APP CHANNELS (what referral stats cannot see) ═══
// StatCounter's ENTIRE tracked universe is Western referral platforms — measured:
// Facebook/Instagram/Twitter/YouTube/Pinterest/reddit/LinkedIn/Tumblr/HN/VK/Fark.
@ -572,7 +582,11 @@ if (has('top-apps-by-country.json')) {
for (const c of Object.values(apps.countries)) {
const meta = g[c.iso3] || {};
// One row per COUNTRY x APP so a list view can sort "where does Telegram rank".
for (const a of (c.comms_apps || [])) {
// The kind is RE-DERIVED from the name with the shared classifier (app-kind.mjs), and the top
// messaging app with it: the cached fetch may carry a kind from an older rule.
const comms = (c.comms_apps || []).map((a) => ({ ...a, kind: classify(a.name) })).filter((a) => isComms(a.kind));
const topMessaging = comms.slice().sort((x, y) => x.rank - y.rank).find((a) => a.kind === 'messaging')?.name || null;
for (const a of comms) {
rows.push({
country: meta.country || c.iso3,
iso3: c.iso3,
@ -580,7 +594,7 @@ if (has('top-apps-by-country.json')) {
app: a.name,
kind: a.kind,
rank: a.rank,
is_top_messaging: a.name === c.top_messaging,
is_top_messaging: a.name === topMessaging,
people_groups: meta.groups || 0,
platform_bias: c.platform_bias,
classification: c.classification_method,
@ -605,7 +619,7 @@ if (has('top-apps-by-country.json')) {
], {
title: 'Messaging & App Channels',
grain: 'one row per country x communication app',
why: 'Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp is the top messaging app in 54 countries, Telegram in 38 — neither appears in referral share at all.',
why: messagingWhy(rows),
ios_caveat: 'iOS ONLY. Most of these countries are Android-majority and Google publishes no keyless equivalent, so this is one eye open. An app absent here is NOT absent in-country. 50 of 175 countries have no Apple storefront.',
ranking_caveat: 'A top-free RANK is most-DOWNLOADED recently, not most-used.',
classification_caveat: 'kind is a name-keyword HEURISTIC over a known list, not a verified taxonomy.',

View file

@ -40,6 +40,7 @@
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { classify, isComms } from './app-kind.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = join(__dirname, 'out');
@ -55,19 +56,7 @@ const SOURCE = {
why: 'StatCounter tracks only Western referral platforms and cannot see WhatsApp/WeChat/Telegram/TikTok/LINE/Viber/imo/Zalo — the channels that dominate most of this corpus',
};
// Known non-Western + messaging platforms worth surfacing explicitly. Matching is
// a HEURISTIC on the app name; recorded as such.
const KIND = [
[/\b(whatsapp|telegram|signal|viber|imo|line|zalo|wechat|weixin|kakaotalk|botim|imessage|messenger|skype|hike|jio ?chat|soul|beetalk)\b/i, 'messaging'],
[/\b(tiktok|douyin|kwai|likee|snack ?video|moj|josh|triller|reels)\b/i, 'short-video'],
[/\b(facebook|instagram|snapchat|x|twitter|threads|vk|odnoklassniki|weibo|qzone|xiaohongshu|little red|sharechat|koo|mastodon|bluesky)\b/i, 'social'],
[/\b(youtube|bigo|nimo|twitch|rumble|dailymotion|vimeo)\b/i, 'video'],
[/\b(opera|uc ?browser|chrome|firefox|brave|puffin|phoenix ?browser)\b/i, 'browser'],
];
const classify = (name) => {
for (const [re, k] of KIND) if (re.test(name)) return k;
return 'other';
};
// The classifier (KIND / classify) is shared with build-tabulator-tables.mjs: see app-kind.mjs.
const ISO2 = existsSync(join(OUT, 'iso3-to-iso2.json'))
? JSON.parse(readFileSync(join(OUT, 'iso3-to-iso2.json'), 'utf8'))
@ -113,7 +102,7 @@ for (let n = 0; n < todo.length; n++) {
}
} catch (e) { err = String(e.message || e).slice(0, 60); }
const comms = apps.filter((a) => a.kind !== 'other' && a.kind !== 'browser');
const comms = apps.filter((a) => isComms(a.kind));
out[iso3] = {
iso3, iso2: ISO2[iso3],
apps: apps.slice(0, 25),

View file

@ -2,7 +2,7 @@
"meta": {
"title": "Messaging & App Channels",
"grain": "one row per country x communication app",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp is the top messaging app in 54 countries, Telegram in 38 — neither appears in referral share at all.",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp (Messenger or Business) is the top messaging app in 73 countries, Telegram in 38 — neither appears in referral share at all.",
"ios_caveat": "iOS ONLY. Most of these countries are Android-majority and Google publishes no keyless equivalent, so this is one eye open. An app absent here is NOT absent in-country. 50 of 175 countries have no Apple storefront.",
"ranking_caveat": "A top-free RANK is most-DOWNLOADED recently, not most-used.",
"classification_caveat": "kind is a name-keyword HEURISTIC over a known list, not a verified taxonomy.",
@ -2830,19 +2830,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Thailand",
"iso3": "THA",
"region": "Asia",
"app": "LINE MAN: Food Delivery & more",
"kind": "messaging",
"rank": 13,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Burkina Faso",
"iso3": "BFA",
@ -3006,7 +2993,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 37,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
@ -7809,19 +7796,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Maldives",
"iso3": "MDV",
"region": "Asia",
"app": "Malé Taxi Line",
"kind": "messaging",
"rank": 1,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Northern Mariana Islands",
"iso3": "MNP",
@ -13120,7 +13094,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 33,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",

View file

@ -108,6 +108,7 @@
"check:i18n-keys-exist": "node plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs",
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs",
"check:i18n-parity": "node internal/check-i18n-parity.mjs",
"check:i18n-stopped-scope": "node internal/check-i18n-stopped-scope.mjs",
"check:i18n-treeshake": "node internal/check-i18n-treeshake.mjs",
"check:kit-options-reachable": "node plugin-designs/research-dashboard/scripts/check-kit-options-reachable.mjs",
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
@ -195,6 +196,7 @@
"check:script-flags": "node internal/check-script-flags-reachable.mjs",
"check:settled": "node internal/settled-tree-gate.mjs",
"check:sfc-parse": "node internal/check-sfc-script-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",
"check:shared-reached": "node internal/check-shared-component-reached-bundles.mjs",
@ -232,7 +234,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value",
"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",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -339,6 +341,7 @@
"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-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.",
"check:kit-options-reachable": "every option a shared kit composable ACCEPTS must be honoured somewhere in its body. Written because I nearly shipped one: kit landing 3 gave useSelectionCamera a `coords` option so a dashboard whose rows spell their coordinate differently could say so, and it was accepted in the signature and never passed to selectionBounds - a caller could set it, type-check clean, build clean and watch it do nothing. That is the worst shape a missing feature takes, because it READS as supported and nobody files a bug against a knob they assume they misused; this tree already paid for it once with a defineProps on a component the custom element never binds. It matters more for the kit than for one dashboard: the whole promise of SPEC-DASHBOARD-KIT is that a config produces a working dashboard, and a parameter that is merely present turns that promise into a guess. Strips comments from BOTH the signature and the body before searching - the first version was satisfied by the comment beside the fixed call site, and the second read comment prose as option names. Not a type checker: it catches a completely unused option, which is the failure invisible from outside.",
"check:language-code-link": "the map's language selection reaches the grid as a locale-stable CODE, or narrows nothing at all. rega measured Languages > Language > \"Arabic\" taking the grid 2,107 -> 0 in BOTH locales: the emitter looked for a row whose language LABEL equalled the selected name, but a language tier's label is derived by splitting the API label at the comma, and no row reads \"Arabic\" - they read \"Arabic, Sudanese\", \"Arabic, Levantine\", 22 codes in all. Lifts the emitter's match rule and the composite's gridLanguage() from the shipping source (never re-typed) and runs them against a committed fixture of the 864 distinct language label/value pairs: the emitter's rule must agree with the legend's own three-arm filter on every (label, tier) pair, every tier must select at least one row, a tier covering one code must emit it, a tier covering several must emit NONE and leave the axis unfiltered. No network, no browser; --refresh-fixture re-takes the fixture.",

View file

@ -239,7 +239,7 @@ async function load() {
// review caught it as "India seems to have no internet coverage". The fix was a
// second file, world-adm0-plus.geojson; that file is gone and India is fetched
// at ADM0 like every other country (data/source-boundaries.mjs).
fetch(`${assetsBase.value}/boundaries/world-adm0.geojson`).then((r) => r.json()),
fetch(`${assetsBase.value}/boundaries/world-adm0-web.geojson`).then((r) => r.json()),
])
rows.value = data.countries || []
return adm0
@ -304,7 +304,7 @@ async function loadAdm1() {
fetch(`${assetsBase.value}/tables/internet-by-adm1.json`).then((r) => { if (!r.ok) throw new Error('internet-by-adm1 ' + r.status); return r.json() }),
// All 36 states (data/boundaries/gb-IND-ADM1-full.geojson). NOT gb-IND-ADM1.geojson: that one is
// UPG-100's partition, curated to 34 units, and it left Mizoram and Nagaland with no geometry.
fetch(`${assetsBase.value}/boundaries/gb-IND-ADM1-full.geojson`).then((r) => { if (!r.ok) throw new Error('gb-IND-ADM1-full ' + r.status); return r.json() }),
fetch(`${assetsBase.value}/boundaries/gb-IND-ADM1-full-web.geojson`).then((r) => { if (!r.ok) throw new Error('gb-IND-ADM1-full ' + r.status); return r.json() }),
])
const byArea = {}
for (const r of table.rows || []) {

View file

@ -2,7 +2,7 @@
"meta": {
"title": "Messaging & App Channels",
"grain": "one row per country x communication app",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp is the top messaging app in 54 countries, Telegram in 38 — neither appears in referral share at all.",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp (Messenger or Business) is the top messaging app in 73 countries, Telegram in 38 — neither appears in referral share at all.",
"ios_caveat": "iOS ONLY. Most of these countries are Android-majority and Google publishes no keyless equivalent, so this is one eye open. An app absent here is NOT absent in-country. 50 of 175 countries have no Apple storefront.",
"ranking_caveat": "A top-free RANK is most-DOWNLOADED recently, not most-used.",
"classification_caveat": "kind is a name-keyword HEURISTIC over a known list, not a verified taxonomy.",
@ -2830,19 +2830,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Thailand",
"iso3": "THA",
"region": "Asia",
"app": "LINE MAN: Food Delivery & more",
"kind": "messaging",
"rank": 13,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Burkina Faso",
"iso3": "BFA",
@ -3006,7 +2993,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 37,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
@ -7809,19 +7796,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Maldives",
"iso3": "MDV",
"region": "Asia",
"app": "Malé Taxi Line",
"kind": "messaging",
"rank": 1,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Northern Mariana Islands",
"iso3": "MNP",
@ -13120,7 +13094,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 33,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",

View file

@ -1114,7 +1114,7 @@ async function load() {
// review caught it as "India seems to have no internet coverage". The fix was a
// second file, world-adm0-plus.geojson; that file is gone and India is fetched
// at ADM0 like every other country (data/source-boundaries.mjs).
fetch(`${assetsBase.value}/boundaries/world-adm0.geojson`).then((r) => r.json()),
fetch(`${assetsBase.value}/boundaries/world-adm0-web.geojson`).then((r) => r.json()),
// 650KB and it is the point of the map, so it loads with the boundaries rather
// than behind a tap. URLs are normalised into `codes` by ISO 639-3, so the 12,379
// pins carry a code rather than 12,379 copies of the same jesusfilm.org link.
@ -1270,7 +1270,7 @@ async function ensureProvinceTable() {
return provinceRows.value
}
const PROVINCE_FILE = { IND: 'gb-IND-ADM1-full.geojson', CHN: 'gb-CHN-ADM1.geojson' }
const PROVINCE_FILE = { IND: 'gb-IND-ADM1-full-web.geojson', CHN: 'gb-CHN-ADM1.geojson' }
async function ensureProvinces(iso3) {
const map = mapRef.value

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -85,6 +85,16 @@ function leafPopulation(row, records) {
export function buildLegendNodes(clusters, opts = {}) {
const deafCount = opts.deafCount ?? 123;
const records = Array.isArray(opts.records) ? opts.records : null;
// ★ OPTIONAL GENERATED TITLE (Driver, 2026-09-27). `opts.title(zone)` returns a localized title
// composed from already-translated parts, or NULL for a zone whose title cannot be generated — see
// data/zone-title.js. Passing nothing keeps every hand-written English label exactly as it was, which
// is why this is an option rather than a rewrite: the legend, the poster and the packet adopt it
// independently, and a caller that has no records yet (records:null) still builds a correct tree.
// ⚠ NULL MEANS "KEEP YOURS", NOT "NO TITLE". 18 of 28 zones decline (their labels are sub-national or
// taxonomic, and Intl has no subdivisions), so the fallback is the normal path, not the error path.
const titleOf = typeof opts.title === 'function'
? (zone, fallback) => opts.title(zone) || fallback
: (_zone, fallback) => fallback;
const lists = clusters.lists || [];
const asia = clusters.asia_lists || [];
// slice 09b-6 §1(a): wire the 7 uncovered "Frontier Asia" zones (Nepal,
@ -137,7 +147,7 @@ export function buildLegendNodes(clusters, opts = {}) {
{
id: l.id,
zoneKey: l.id,
label: l.label,
label: titleOf(l, l.label),
// Carry the province enumeration the region-name label replaced.
...(l.provinceLabel ? { provinceLabel: l.provinceLabel } : {}),
color: colorOf(l.id),
@ -171,7 +181,7 @@ export function buildLegendNodes(clusters, opts = {}) {
{
id: r.id,
zoneKey: r.id,
label: r.label,
label: titleOf(r, r.label),
// The province enumeration the region-name label replaced. Carried through
// like adm0/provinces above — a node rebuilt field-by-field silently drops
// anything not named, which is exactly how adm0 and population were lost.
@ -228,7 +238,7 @@ export function buildLegendNodes(clusters, opts = {}) {
{
id: u.id,
zoneKey: u.id,
label: u.label,
label: titleOf(u, u.label),
// Carry the province enumeration the region-name label replaced.
...(u.provinceLabel ? { provinceLabel: u.provinceLabel } : {}),
color: colorOf(u.id),

View file

@ -0,0 +1,178 @@
/**
* zone-title — compose a zone's DISPLAY TITLE from parts that are ALREADY translated, so adding a
* project list costs no new translation and adding a locale costs no code change.
*
* THE PROBLEM (Driver, 2026-09-27). Every zone label is a hand-written English string — "Middle East
* Core", "Malabar Coast" — so the Arabic map reads English. Hand-translating them does not scale,
* because UPG-100 is becoming a PATTERN: Project 7000 / 4700 / 3000 / 2000 / 700 each invent their own
* ~100 clusters, and a per-title translation is a cost paid again for every list, forever. The Driver's
* question was precisely "how do you generate a title without a new translation each time".
*
* ★ WHAT THE DATA ACTUALLY ALLOWS, measured before any of this was written (2026-09-27):
*
* SOURCE COVERAGE OF OUR DATA FAILURE MODE
* country_code_label from the records API all 175 codes, every locale (absent field)
* Intl.DisplayNames({type:'region'}) all 175, every locale THROWS on ISO-3
* Intl.DisplayNames({type:'language'}) 213 of 2242 = 9.5% ECHOES THE CODE
*
* ⚠ LANGUAGE IS NOT A USABLE TITLE SOURCE, which kills the more attractive half of the original idea.
* `new Intl.DisplayNames(['en'],{type:'language'}).of('kfa')` returns the STRING "kfa" — it does not
* throw — so a miss prints a raw code onto a printed poster with nothing raising a hand. And the 90.5%
* miss is STRUCTURAL rather than a gap someone can fill: unreached peoples speak small languages by
* definition, and CLDR carries the big ones. So this module never names a language.
*
* ★ AND COUNTRY NAMES COME FROM THE RECORDS, NOT FROM Intl, which was the measurement that simplified
* the whole design. The records API already returns `country_code_label` localized by its own `lang`
* parameter — verified live: IND is الهند / भारत / 印度 / Индия for lang=ar/hi/zh/ru, matching Intl
* exactly. Since the packet and the page ALREADY fetch these records at the reader's locale, the
* localized name is free and needs no ISO-3166 alpha-3 → alpha-2 mapping at all. Intl is kept only as a
* FALLBACK for a caller who has a code and no record.
*
* WHICH ZONES THIS CAN AND CANNOT TITLE — stated here because a generator that silently produces a bad
* title is worse than one that declines:
* TIER A selector country_code → one country name. GENERATED
* TIER B selector country_codes → 2..n country names + a joiner. GENERATED
* TIER C wagf_block / wagf_region / pg_slugs / union / affinity_bloc → null. DECLINED
* Tier C is 18 of our 28 zones and it cannot be done from country names: the 12 India/China zones are
* `pg_slugs` whose every record is ONE country (measured: asia-india-kerala is 78/78 IND), so countries
* would render "India" twelve times and could not tell "Malabar Coast" from "Northwest India" — those
* labels are SUB-NATIONAL. Naming them needs a province/subdivision source, which Intl has none of; the
* curated-table-vs-keep-English choice is with the Driver (repo's digest item D). Until it is decided
* this returns null for tier C and the caller keeps the existing label, so nothing regresses.
*
* THE ONLY TRANSLATED STRINGS ARE THE TWO JOINERS — `zone_join` ("{a} + {b}") and `zone_join_more`
* ("{a} + {n} more") — in packet-strings.json, one pair per locale block. That is the entire
* translation cost for every project list that ever uses this.
*/
/** Tier A/B selector types. Anything else is tier C and declined. */
const COUNTRY_SELECTORS = new Set(['country_code', 'country_codes'])
/**
* The ISO-3 codes a selector names, in the order the selector lists them.
* ⚠ ORDER IS THE AUTHOR'S, NOT ALPHABETICAL. clusters.json lists the countries of a zone with the
* biggest/most representative first (middle-east-core starts IRN,YEM,IRQ), and that order is editorial
* information: it is what makes "Iran + Yemen + 8 more" a better title than "Bahrain + Iran + 8 more".
* Sorting here would discard a judgement someone already made.
*/
export function zoneCountryCodes(selector) {
if (!selector || !COUNTRY_SELECTORS.has(selector.type)) return []
if (selector.type === 'country_code') return selector.value ? [selector.value] : []
return Array.isArray(selector.values) ? selector.values.filter(Boolean) : []
}
/**
* ISO-3 → a localized country name.
*
* @param {string} iso3
* @param {object} opts
* @param {Map<string,string>} [opts.byCode] ISO-3 → country_code_label, built from the records this
* page/packet already loaded AT THE READER'S LOCALE. The preferred source: no alpha-2 mapping,
* no second service, and it agrees with Intl where both have an answer.
* @param {string} [opts.locale] used only by the Intl fallback.
* @param {(iso3:string, locale:string)=>string} [opts.countryName] regions-plugin's coming library
* helper (their card BUILD-LOCALIZED-COUNTRY-NAMES-ISO3-TO-ISO2). Injected rather than imported
* so this module does not depend on an unlanded file; when it lands, pass it and it wins over
* the local Intl fallback.
* @returns {string|null} null rather than the raw code — see below.
*/
export function countryLabel(iso3, opts = {}) {
const code = String(iso3 || '').toUpperCase()
if (!code) return null
// ★ ORDER: regions' helper (or Intl) FIRST, the records' label SECOND — and this order is the
// opposite of the one I wrote first, corrected by measuring what the two sources actually say.
// The records API returns the FORMAL country name, which is wrong for a poster header:
// LAO "Lao People's Democratic Republic" vs Intl "Laos"
// IRN "Islamic Republic of Iran" vs Intl "Iran"
// CHN "People's Republic of China" vs Intl "China"
// 28 of our 175 country labels are long formal forms like these. My first version preferred the
// records because they were already localized and needed no code mapping — true, and it produced
// "Lao People's Democratic Republic" as the title of the zone whose hand-written label was "Laos",
// i.e. a generated title WORSE than the one it replaced. Intl gives the short common form in every
// locale, which is what a header and a 380px legend row want.
// The records stay as the fallback because they need no alpha-2 mapping and are always present.
if (typeof opts.countryName === 'function') {
const n = opts.countryName(code, opts.locale || 'en')
if (n && n !== code) return n
}
// ⚠ Intl REGIONS TAKE ALPHA-2 AND THROW ON ALPHA-3 — `new Intl.DisplayNames(['en'],{type:'region'})
// .of('IND')` is a RangeError, not a miss. So a 3-letter code needs a mapping, and `alpha2` is that
// mapping, INJECTED rather than imported: regions-plugin owns the ISO-3→ISO-2 table (their card
// BUILD-LOCALIZED-COUNTRY-NAMES-ISO3-TO-ISO2) and it has not landed, so this module must work without
// it and improve when it arrives. With no mapper a 3-letter code simply falls through to the records
// label below — the formal name, but a real localized name.
// That THROW-vs-ECHO asymmetry with the language type is also why every lookup here ends in null
// rather than in a code: one failure is loud, the other silent, and null normalises both.
const two = code.length === 2 ? code
: (typeof opts.alpha2 === 'function' ? String(opts.alpha2(code) || '').toUpperCase() : '')
if (two.length === 2) {
try {
const n = new Intl.DisplayNames([opts.locale || 'en'], { type: 'region' }).of(two)
if (n && n !== two) return n
} catch (_) { /* not a region Intl knows */ }
}
// The records' own label: always localized, always present, but the FORMAL form. Second choice, not
// no choice.
const fromRecords = opts.byCode?.get(code)
if (fromRecords) return fromRecords
// ★ NEVER RETURN THE CODE. "SDN + 3 more" on a poster looks like a data bug to a reader and, worse,
// reads as a successful translation to a gate that only checks the string is non-empty. A null makes
// the caller fall back to the existing label, which is English but is at least a WORD.
return null
}
/**
* The generated title for a zone, or null if this zone is not the kind that can be generated.
*
* @param {object} zone a clusters.json zone (needs .selector)
* @param {object} strings the resolved locale block from packet-strings.json (per-KEY fallback to en
* already applied by the caller, as packet.mjs does)
* @param {object} opts as countryLabel, plus:
* @param {number} [opts.maxParts=2] how many countries to name before collapsing to "+ n more".
* 2 by default because these titles print on a poster header and in a legend row: three names
* already wrap at the legend's 380px panel width in ar and hi, where the words are longer.
* @returns {string|null}
*/
export function zoneTitle(zone, strings, opts = {}) {
const codes = zoneCountryCodes(zone?.selector)
if (!codes.length) return null // tier C — declined, caller keeps its label
const maxParts = Math.max(1, opts.maxParts ?? 2)
const named = codes.map((c) => countryLabel(c, opts))
// ★ ALL-OR-NOTHING ON THE NAMED HEAD. If any of the countries we would SHOW has no name, decline the
// whole title rather than printing a partial one: a title that names two of three countries and drops
// the third silently misinforms, and it cannot be distinguished from a correct title downstream.
const head = named.slice(0, maxParts)
if (head.some((n) => !n)) return null
if (codes.length === 1) return head[0]
if (codes.length <= maxParts) {
// Fold left so three names read "a + b + c" using the same two-part joiner, rather than needing a
// separate three-part string per locale.
return head.reduce((acc, n) => fill(strings?.zone_join, { a: acc, b: n }) ?? `${acc} + ${n}`)
}
const rest = codes.length - maxParts
const joined = head.reduce((acc, n) => fill(strings?.zone_join, { a: acc, b: n }) ?? `${acc} + ${n}`)
return fill(strings?.zone_join_more, { a: joined, n: rest }) ?? `${joined} + ${rest} more`
}
/** `{a}` / `{b}` / `{n}` interpolation. Returns null on a missing pattern so the caller can default. */
function fill(pattern, params) {
if (!pattern) return null
return String(pattern).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m))
}
/**
* ISO-3 → localized country label, from records the caller already has.
* Built once per render rather than scanned per zone: 2107 records x 28 zones is 59k scans for data
* that does not change during a render.
*/
export function countryLabelIndex(records) {
const m = new Map()
for (const r of records || []) {
const cc = r?.country_code
const label = r?.country_code_label
if (cc && label && !m.has(cc)) m.set(String(cc).toUpperCase(), label)
}
return m
}

View file

@ -612,14 +612,32 @@ useShadowStyles(
// The detail panel is the legend's mirror image (it sits top-RIGHT in LTR), so it has to swap too or
// the two would trade places and collide on the other side.
'[dir="rtl"] .upg-detail-panel{right:auto!important;left:8px!important}' +
// ⚠ AND THE ROWS THEMSELVES. dir on the wrapper does NOT reach content that sets its own direction,
// and the legend's rows render zone names + numbers that must read right-to-left as a unit. Without
// this the panel mirrors but its contents stay LTR — which is what the picture shows.
'[dir="rtl"] .stl-panel,[dir="rtl"] .stl-panel *{direction:rtl}' +
// Numbers stay LTR inside an RTL line: a count or a percentage is a left-to-right run even in Arabic,
// and without isolation the bidi algorithm reorders a run that STARTS with a digit. Same fix the
// packet needed for scripture references.
'[dir="rtl"] .stl-panel .stl-count,[dir="rtl"] .stl-panel .stl-pop{direction:ltr;unicode-bidi:isolate}' +
// ★ THE TWO LEGEND-CONTENT RULES THAT USED TO SIT HERE ARE DELETED (2026-09-27), and they were NOT
// equally provisional — one was redundant and the other was DEAD, which is worth separating because
// only one of them was ever doing anything.
//
// '[dir="rtl"] .stl-panel,[dir="rtl"] .stl-panel *{direction:rtl}'
// REDUNDANT. It matched real elements (.stl-panel appears 69 times in SemanticTreeLegend) but the
// library gets there on its own: it uses the :dir(rtl) PSEUDO-CLASS (:645-646), which reads the
// INHERITED direction rather than needing a declaration, and CSS `direction` inherits across the
// shadow boundary. rega measured the consequence at main 4ca2dcf9 (row 56b): Simple Research and
// Prayer Progress, which carry NO override at all, compute direction rtl on their legend rows under
// ?lang=ar. A rule whose effect is indistinguishable from its absence on two other profiles is not
// load-bearing here either.
//
// '[dir="rtl"] .stl-panel .stl-count,[dir="rtl"] .stl-panel .stl-pop{direction:ltr;unicode-bidi:isolate}'
// ⚠ DEAD CSS, and it always was. The library's count and population cells are `.stl-num` (:1314-1315).
// `.stl-count` and `.stl-pop` DO NOT EXIST — `git grep` finds those two class names nowhere in
// library/ or any other plugin, only inside this override. So this rule matched zero elements from the
// day it was written, and the digit order rega verified ("694", "1,413" keeping their order under ar)
// was always the library's doing, never mine.
// ★ THE LESSON IS ABOUT THE EVIDENCE, NOT THE CSS: this rule was written to fix a real bidi problem,
// was believed to have fixed it, and was cited as the reason the numbers read correctly — while
// matching nothing. A style rule fails SILENTLY when its selector is wrong; it does not warn, and the
// page looks right if something else is already handling it. The only way to know a rule works is to
// check that its selector exists, which is a different act from looking at the rendered page.
// (dx-shepherd UPGDEL authorised deleting both as provisional; recording here that one of the two was
// never provisional but inert, so nobody re-adds it believing it once worked.)
// ⚠ AND THE REOPEN BUTTON, which I missed on my first pass and repo caught. SemanticTreeLegend has
// TWO physical `left:8px` rules, not one: :619 for the panel and :921 for .stl-reopen, the button that
// brings the panel back after it is collapsed. Mirroring only the panel would leave the button on the
@ -2192,10 +2210,10 @@ onMounted(async () => {
// and it overlapped nothing: a promise created after an await starts only once that await
// resolves, so the code read as parallel and behaved strictly serially.
const boundaryPromises = {
adm0: getJSON('world-adm0.geojson'),
adm0: getJSON('world-adm0-web.geojson'),
adm1Chn: getJSON('gb-CHN-ADM1.geojson'),
// 1,628 KB, and only the India pg_slugs zones need it.
adm1Ind: getJSON('gb-IND-ADM1.geojson'),
adm1Ind: getJSON('gb-IND-ADM1-web.geojson'),
}
// ── live records WITH rop1 + population (full record; LESSON L3 / slice 04u) ──

View file

@ -193,14 +193,66 @@ function paneElement(pane) {
// `lang` is the alias the page may carry; children read `locale`.
if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang
const pcfg = { ...inherited, ...spec.value.config, ...pane.config }
// ★ NO "TAP A COLOURED DOT" COACH-MARK ON THIS PROFILE, AT ALL (Driver, 2026-09-27).
// ProfileLoader mounts PinHint by default and self-gates it on a map <canvas> existing, which is a
// sound gate for the question it asks ("is there a map here?") and the WRONG question for this
// profile. UPG-100's map is a POSTER PREVIEW: the reader is looking at what will be printed, and there
// is no per-dot detail to open. So the pill invites a gesture that does nothing, and on the phone the
// Driver's screenshots show it over a page with no coloured dots on it at all.
// ⚠ THIS SUPERSEDES THE inert WORK FOR THIS PROFILE, and that is worth saying plainly rather than
// quietly leaving both in: PILLRISK was "the pill shows over the posters", and I fixed WHERE it shows.
// The Driver's answer is that it should not show HERE, full stop — a narrower and more correct claim
// than mine. The inert signal stays because it is still right for its own reasons (a stacked pane
// should not be in tab order or the a11y tree, and studio's PinHint change serves profiles that DO
// want the pill), but it is no longer what keeps this pill off this app.
// Opt-out via the contract that already existed — ProfileLoader.vue:150 reads
// `config.pinHint !== false` — so this is a config value from my side, not a library change.
pcfg.pinHint = false
if (tk.value) pcfg.tk = tk.value
el.setAttribute('profile-config', JSON.stringify(pcfg))
return el
}
// ★ TELL THE INVISIBLE PANE THAT IT IS INVISIBLE. `inert` on every pane except the active one.
//
// THE DEFECT, reported by the Driver and traced by repo (PILLRISK, 2026-09-27): PinHint's "Tap a
// coloured dot" pill appeared OVER the Prayer Resources posters on a phone. Its IntersectionObserver
// was working correctly — that is the point. The panes are `position:absolute; inset:0` in one
// scroll-snap column, so the map pane is STACKED BEHIND the active view at full size: 100% of its
// geometry is inside the viewport while none of it is visible. An IntersectionObserver measures
// INTERSECTION, not OCCLUSION, and no amount of tuning it can distinguish those two states. The
// missing information was never geometric — only the composite knows which pane the reader chose.
//
// ⚠ inert, NOT display:none OR v-show, and this is the constraint that decides the whole approach:
// hiding the pane would tear down the live map. A MapLibre/Mapbox canvas in a zero-size container
// loses its size and re-initialises on the way back, which is exactly the cold-start path rega had to
// verify in row 52. inert leaves the pane mounted, painted and warm; it only declares it non-interactive
// — and, unlike aria-hidden, also removes it from tab order and the accessibility tree, so a keyboard
// user cannot tab into a map they cannot see. (studio's contract, END-STU-PILL: PinHint walks up through
// shadow roots for inert/hidden/aria-hidden and watches that chain with a MutationObserver, so no event
// is needed and switching views brings the pill straight back.)
//
// ★ CORRECT AT EVERY VIEWPORT, not just the phone. pane-wrapper renders ONE column with
// `scroll-snap-type: y mandatory` and `.pane { height: 100% }`, so exactly one pane fills the stage on
// any width — there is no side-by-side mode in v1 whose second pane would be wrongly marked. Verified
// in pane-wrapper.js:56-59 + :101 rather than assumed from the phone report.
function markActivePane(activeId) {
if (!column) return
for (const pane of column.panes) {
const el = column.mountEl(pane.id)
if (!el) continue
if (pane.id === activeId) el.removeAttribute('inert')
else el.setAttribute('inert', '')
}
}
function go(id) {
view.value = id
column?.scrollTo(id)
// Immediately, not on the scroll callback: scrollTo is SMOOTH, so onScroll's 120ms debounce fires
// only after the animation settles. Waiting for it would leave the pill visible over the posters for
// the length of the scroll — a shorter version of the same bug.
markActivePane(id)
}
// Cross-pane action (shell seam): the poster wall dispatches a scoped window event
@ -253,10 +305,21 @@ onMounted(() => {
paneEls[pane.id] = el
column.mountEl(pane.id)?.appendChild(el)
}
// ★ THE FRESH-LOAD CASE, which is the one the Driver actually hit. Without this the panes carry no
// inert attribute at all until the reader touches the switcher, so the pill is over the posters on
// ARRIVAL — before any view change has happened. A signal that only fires on transitions never
// describes the initial state.
markActivePane(column.current())
// Tabs are a readout as well as a control — follow the user's scroll.
offScroll = column.onScroll(() => {
const cur = column.current()
if (cur && cur !== view.value) view.value = cur
// Unconditionally, NOT inside the `cur !== view.value` guard above: go() has already set view.value
// before this fires, so a switcher-driven scroll takes the early exit and would skip the marking. A
// scroll-driven one needs it and a switcher-driven one is idempotent, so doing it always is both
// cheaper to reason about and the only version that is correct on both paths.
if (cur) markActivePane(cur)
})
window.addEventListener('doxa-upg-100-list:show-on-map', onShowOnMap)
})

View file

@ -74,7 +74,7 @@ useShadowStyles(
<div class="pdm-root">
<div class="pdm-bar">
<span class="pdm-brand">PPLR data maps</span>
<div class="pdm-seg" role="tablist" aria-label="Choose a view">
<div class="pdm-seg" role="tablist" :aria-label="t('pplr.ui.chooseView')">
<button v-for="v in VIEWS" :key="v.key" role="tab" :aria-selected="view === v.key"
:class="{ on: view === v.key }" @click="setView(v.key)">{{ t(v.tKey) }}</button>
</div>

View file

@ -9,6 +9,7 @@
* component into its toolbar.
*/
import { ref } from 'vue'
import { usePplrT } from '../composables/usePplrT.js'
defineProps({
/** Array of { label, url, fields[] } */
@ -16,19 +17,20 @@ defineProps({
})
const open = ref(false)
const t = usePplrT()
function toggle() { open.value = !open.value }
</script>
<template>
<div class="dsd-wrap">
<button class="toolbar-btn round" type="button" title="Data sources" @click="toggle">
<button class="toolbar-btn round" type="button" :title="t('pplr.ui.dataSources')" @click="toggle">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="open" class="dsd-panel">
<div class="dsd-header">Data sources</div>
<div class="dsd-header">{{ t('pplr.ui.dataSources') }}</div>
<div v-for="src in sources" :key="src.url" class="dsd-src">
<div class="dsd-src-label">{{ src.label }}</div>
<div class="dsd-src-url">{{ src.url }}</div>

View file

@ -20,6 +20,7 @@
*/
import { ref, computed } from 'vue'
import { useInstance } from '../composables/usePplrInstance.js'
import { usePplrT } from '../composables/usePplrT.js'
const props = defineProps({
items: { type: Array, default: () => [] },
@ -30,6 +31,7 @@ const props = defineProps({
const emit = defineEmits(['select'])
const instance = useInstance()
const t = usePplrT()
const collapsed = ref(false)
const theme = computed(() => instance?.theme.value || 'dark')
@ -77,7 +79,7 @@ function clearSel() {
</svg>
</button>
<div class="fl-title">{{ title }}</div>
<button v-if="selectedKey" class="fl-clear" title="Clear selection (Esc)" @click="clearSel">×</button>
<button v-if="selectedKey" class="fl-clear" :title="t('pplr.ui.clearSelectionEsc')" @click="clearSel">×</button>
</div>
<div class="fl-rows" :class="{ 'has-sel': !!selectedKey }">
<button
@ -99,12 +101,12 @@ function clearSel() {
<button v-if="collapsed"
class="fl-reopen"
:data-theme="theme"
title="Open legend"
:title="t('pplr.ui.openLegend')"
@click="collapsed = false">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<polyline points="9,18 15,12 9,6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Legend</span>
<span>{{ t('pplr.ui.legend') }}</span>
</button>
</template>

View file

@ -12,12 +12,14 @@
* <LoadingGlobe v-if="!isReady" :label="loadingStage" @retry="retry" />
*/
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { usePplrT } from '../composables/usePplrT.js'
const props = defineProps({
label: { type: String, default: 'Loading map' },
label: { type: String, default: '' }, // empty = the translated "Loading map"
stuckAfterMs:{ type: Number, default: 30000 },
})
const emit = defineEmits(['retry'])
const t = usePplrT()
const stuck = ref(false)
let stuckTimer = null
@ -56,13 +58,13 @@ function onRetry() {
<line x1="6" y1="32" x2="58" y2="32" stroke="#73A17F" stroke-width="1.2" opacity="0.45"/>
<line x1="32" y1="6" x2="32" y2="58" stroke="#73A17F" stroke-width="1.2" opacity="0.45"/>
</svg>
<span class="lg-text">{{ label }}</span>
<span class="lg-text">{{ label || t('pplr.ui.loadingMap') }}</span>
<button v-if="stuck" class="lg-retry" type="button" @click="onRetry">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-3-6.7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<polyline points="21 4 21 11 14 11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Retry
{{ t('pplr.ui.retry') }}
</button>
</div>
</template>

View file

@ -19,6 +19,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useInstance } from '../composables/usePplrInstance.js'
import { LOCALES, LOCALE_NAMES } from '../utils/pplrMap.js'
import { loadNames } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'
const props = defineProps({
/** Optional override — usually read from instance.pinDetail */
@ -30,6 +31,7 @@ const props = defineProps({
const emit = defineEmits(['close', 'locate'])
const instance = useInstance()
const tr = usePplrT() // not `t`: the names table below is already called t
const pinData = computed(() => props.pin || instance?.pinDetail.value || null)
// All 17 locale names for this PG (n_en, n_hi, n_bn, …). Per user 2026-05-02:
@ -59,12 +61,12 @@ const fields = computed(() => {
const dash = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
// Curated field order — most identifying first, registry IDs last
return [
{ label: 'Country', value: dash(p.country), mono: false },
{ label: 'Religion', value: dash(p.religion), mono: false },
{ label: 'Language', value: dash(p.language), mono: false },
{ label: 'ISO-3 Country', value: dash(p.iso3), mono: true, hidden: !p.iso3 },
{ label: 'Affinity Bloc', value: dash(p.rop1), mono: true, hidden: !p.rop1 },
{ label: 'People Cluster', value: dash(p.rop2), mono: true, hidden: !p.rop2 },
{ label: tr('pplr.popup.country'), value: dash(p.country), mono: false },
{ label: tr('pplr.popup.religion'), value: dash(p.religion), mono: false },
{ label: tr('pplr.popup.language'), value: dash(p.language), mono: false },
{ label: tr('pplr.popup.iso3Country'), value: dash(p.iso3), mono: true, hidden: !p.iso3 },
{ label: tr('pplr.popup.affinityBloc'), value: dash(p.rop1), mono: true, hidden: !p.rop1 },
{ label: tr('pplr.popup.peopleCluster'), value: dash(p.rop2), mono: true, hidden: !p.rop2 },
{ label: 'ROP25', value: dash(p.rop3), mono: true, hidden: !p.rop3 },
{ label: 'PGIC GeoID', value: dash(p.rop25), mono: true, hidden: !p.rop25 },
{ label: 'PEID', value: dash(p.peid), mono: true, hidden: !p.peid },
@ -98,8 +100,8 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
<!-- Title + close -->
<div class="pdc-header">
<span class="pdc-eyebrow">People Group</span>
<button class="pdc-close" title="Close (Esc)" @click="close">
<span class="pdc-eyebrow">{{ tr('pplr.popup.peopleGroup') }}</span>
<button class="pdc-close" :title="tr('pplr.ui.closeEsc')" @click="close">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path d="M2 2L10 10M10 2L2 10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
@ -117,7 +119,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</div>
<div class="pdc-section-divider">
<span>Name in 17 locales</span>
<span>{{ tr('pplr.card.nameInLocales', { n: locales.length }) }}</span>
</div>
<div v-for="loc in locales" :key="loc.code" class="pdc-field pdc-locale">
<span class="pdc-field-label">{{ loc.name }} <code>{{ loc.code }}</code></span>
@ -134,9 +136,9 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
<path d="M12 21s7-7 7-12a7 7 0 1 0-14 0c0 5 7 12 7 12z" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="9" r="2.5" stroke="currentColor" stroke-width="2"/>
</svg>
Fly to pin
{{ tr('pplr.ui.flyToPin') }}
</button>
<button class="pdc-btn" @click="close">Close</button>
<button class="pdc-btn" @click="close">{{ tr('pplr.ui.close') }}</button>
</div>
</div>
</template>

View file

@ -34,6 +34,7 @@ const A11Y_EN = {
'legend.loadingRows': 'Loading {label}…', 'legend.loadingData': 'Loading data…',
'legend.noDataAtThisLevel': 'No data at this level', 'legend.showingTop': 'Showing top {n} of {m}',
'legend.useGeocoderHint': 'use the geocoder above to find the rest',
'legend.sortByPop': 'Sort by Population',
}
const fillA11y = (str, p) => String(str).replace(/\{(\w+)\}/g, (m, k) => (p && k in p ? p[k] : m))
let ta = (key, p) => fillA11y(A11Y_EN[key] ?? key, p)
@ -441,7 +442,7 @@ function rowClasses(row) {
</button>
<button v-if="hasPop" class="stl-hdr-num"
:class="{ active: sortBy === 'pop' }"
title="Sort by Population"
:title="ta('legend.sortByPop')"
@click="clickSortHeader('pop')">
Pop
<span v-if="sortBy === 'pop'" class="stl-sort-arrow">{{ sortDir === 'desc' ? '▼' : '▲' }}</span>

View file

@ -30,6 +30,7 @@ export const LOCALE_NAMES = {
// ── PPLR FeatureServer — country polygons (live, not bundled) ────────────────
import { dataUrl, glFor } from './pplrData.js'
import { tStatic } from '@map/i18n/index.js'
export const COUNTRY_FS_URL = 'https://services2.arcgis.com/S4ydGgujXcif36k3/arcgis/rest/services/aCountry/FeatureServer/0/query'
export const COUNTRY_FS_FIELDS = ['GENCalpha3', 'ISOalpha2', 'CtryNm', 'ctryNmDisp']
@ -332,17 +333,20 @@ export const escHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&':
export function popupForPG(p, extraRows = '') {
const dash = v => (v == null || v === 'None' || v === '' ? '<span class="m">—</span>' : escHtml(v))
// Labels in the page's language (tStatic falls back to English). Registry names (ROP3, ROP25,
// JoshuaProject, IMB People, WCD) are identifiers and stay as they are.
const L = (k) => escHtml(tStatic('pplr.popup.' + k))
return `<div class="pplr-popup">
<h3>${p.name ? escHtml(p.name) : '(no name)'}</h3>
<h3>${p.name ? escHtml(p.name) : L('noName')}</h3>
<table>
<tr><td>Country</td><td>${dash(p.country)} <code>${dash(p.iso3)}</code></td></tr>
<tr><td>${L('country')}</td><td>${dash(p.country)} <code>${dash(p.iso3)}</code></td></tr>
<tr><td>ROP3</td><td><code>${dash(p.rop3)}</code></td></tr>
<tr><td>ROP25 (Ethne)</td><td><code>${dash(p.rop25)}</code></td></tr>
<tr><td>JoshuaProject</td><td><code>${dash(p.jp)}</code></td></tr>
<tr><td>IMB People</td><td><code>${dash(p.imb)}</code></td></tr>
<tr><td>WCD</td><td><code>${dash(p.wcd)}</code></td></tr>
<tr><td>Religion</td><td>${dash(p.religion)}</td></tr>
<tr><td>Language</td><td>${dash(p.language)}</td></tr>
<tr><td>${L('religion')}</td><td>${dash(p.religion)}</td></tr>
<tr><td>${L('language')}</td><td>${dash(p.language)}</td></tr>
${extraRows}
</table>
</div>`

View file

@ -103,8 +103,8 @@ onMounted(async () => {
const html = `<div class="pplr-popup">
<h3>${escHtml(p.CtryNm || p.ctryNmDisp || '—')}<small>${escHtml(p.GENCalpha3 || p.ISOalpha2 || '?')}</small></h3>
<table>
<tr><td>PG count</td><td><b>${escHtml(p.pgCount || 0)}</b></td></tr>
<tr><td>Avg locales filled</td><td>
<tr><td>${escHtml(t('pplr.popup.pgCount'))}</td><td><b>${escHtml(p.pgCount || 0)}</b></td></tr>
<tr><td>${escHtml(t('pplr.popup.avgLocalesFilled'))}</td><td>
<b>${p.avgFilled != null ? p.avgFilled.toFixed(1) : '—'}</b> / 17</td></tr>
</table>
</div>`
@ -148,9 +148,9 @@ const COV_POPUP_CSS = POPUP_CSS + `
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">Translation coverage · choropleth = avg locales filled per country · click PG pin or country</div>
<div class="header-pill">{{ t('pplr.pill.coverage') }}</div>
<div class="legend">
<div class="title">Avg locales / 17</div>
<div class="title">{{ t('pplr.legendTitle.avgLocales', { n: 17 }) }}</div>
<div class="bar">
<div v-for="s in SCALE" :key="s.stop" class="seg" :style="{ background: s.color }">
<span v-if="s.label">{{ s.label }}</span>

View file

@ -65,7 +65,7 @@ onMounted(async () => {
<template>
<div class="profile">
<LoadingGlobe v-if="!tree" label="Loading ROP hierarchy" @retry="reload" />
<LoadingGlobe v-if="!tree" :label="t('pplr.status.loadingRop')" @retry="reload" />
<template v-else>
<SemanticSunburst
:tree="tree"
@ -74,18 +74,18 @@ onMounted(async () => {
:depth-labels="['','Affinity Bloc','People Cluster','PG (not rendered)']"
:max-depth="2"
/>
<div class="header-pill">ROP Hierarchy · Affinity Bloc → People Cluster · scroll to zoom</div>
<div class="header-pill">{{ t('pplr.pill.sunburst') }}</div>
<div class="status">{{ status }}</div>
<div class="map-toolbar">
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>

View file

@ -415,10 +415,10 @@ function popupHtml(p) {
return `<div class="pplr-popup">
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Language</td><td>${d(p.language)}</td></tr>
<tr><td>Family</td><td><b style="color:${color}">${d(p.fam)}</b></td></tr>
<tr><td>Affinity Bloc</td><td>${d(p.rop1)}</td></tr>
<tr><td>Religion</td><td>${d(p.religion)}</td></tr>
<tr><td>${escHtml(t('pplr.popup.language'))}</td><td>${d(p.language)}</td></tr>
<tr><td>${escHtml(t('pplr.popup.family'))}</td><td><b style="color:${color}">${d(p.fam)}</b></td></tr>
<tr><td>${escHtml(t('pplr.popup.affinityBloc'))}</td><td>${d(p.rop1)}</td></tr>
<tr><td>${escHtml(t('pplr.popup.religion'))}</td><td>${d(p.religion)}</td></tr>
<tr><td>IMB PGID</td><td><code>${d(p.imb)}</code></td></tr>
<tr><td>JP Code</td><td><code>${d(p.jp)}</code></td></tr>
</table>
@ -522,7 +522,7 @@ const LF_CSS = POPUP_CSS + `
:local-index="langLocalIndex"
:kind-labels="LANG_KIND_LABELS"
:mapbox-token="mapboxToken"
placeholder="Search by people, place, language, religion…"
:placeholder="t('pplr.ui.search')"
@place="onGeocoderPlace"
/>
</div>
@ -539,13 +539,13 @@ const LF_CSS = POPUP_CSS + `
v-else
:nodes="langTree"
:tabs="LANG_TABS"
title="Language Families"
:title="t('pplr.tabs.languageFamilies')"
@select="onLegendSelect"
/>
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">Ethnolinguistic families (PPLR) · click pin for detail</div>
<div class="header-pill">{{ t('pplr.pill.languageFamilies') }}</div>
<!-- Toolbar — rounded pill controls -->
<div class="map-toolbar">
@ -564,28 +564,28 @@ const LF_CSS = POPUP_CSS + `
<span class="toolbar-icon">{{ projection === 'globe' ? '🌐' : '🗺' }}</span>
</button>
<div class="basemap-wrap">
<button class="toolbar-btn round" title="Base map" @click="showBasemap = !showBasemap">
<button class="toolbar-btn round" :title="t('pplr.ui.baseMap')" @click="showBasemap = !showBasemap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M3 12l9 4 9-4M3 17l9 4 9-4" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showBasemap" class="basemap-panel">
<div class="basemap-title">Base map</div>
<div class="basemap-title">{{ t('pplr.ui.baseMap') }}</div>
<button v-for="b in BASEMAPS" :key="b.key" class="basemap-opt"
:class="{ active: currentBasemap === b.key }"
@click="setBasemap(b.key)">{{ b.label }}</button>
</div>
</div>
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>

View file

@ -68,18 +68,18 @@ onBeforeUnmount(() => destroy())
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">All PG-in-country pins · click for cross-system IDs</div>
<div class="header-pill">{{ t('pplr.pill.pins') }}</div>
<div class="map-toolbar">
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>

View file

@ -236,8 +236,8 @@ function popupHtml(p) {
return `<div class="pplr-popup">
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Religion</td><td><b style="color:${grp?.color || '#ccc'}">${d(p.religion)}</b></td></tr>
<tr><td>Language</td><td>${d(p.language)}</td></tr>
<tr><td>${escHtml(t('pplr.popup.religion'))}</td><td><b style="color:${grp?.color || '#ccc'}">${d(p.religion)}</b></td></tr>
<tr><td>${escHtml(t('pplr.popup.language'))}</td><td>${d(p.language)}</td></tr>
<tr><td>IMB PGID</td><td><code>${d(p.imb)}</code></td></tr>
<tr><td>JP Code</td><td><code>${d(p.jp)}</code></td></tr>
</table>
@ -294,7 +294,7 @@ const REL_CSS = POPUP_CSS + `
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">Religion distribution · click pin for detail</div>
<div class="header-pill">{{ t('pplr.pill.religion') }}</div>
<!-- Decoupled geocoder — searches religions + countries + places -->
<div class="geocoder-slot">
@ -302,7 +302,7 @@ const REL_CSS = POPUP_CSS + `
:local-index="religionLocalIndex"
:kind-labels="REL_KIND_LABELS"
:mapbox-token="mapboxToken"
placeholder="Search by people, place, language, religion…"
:placeholder="t('pplr.ui.search')"
@place="onGeocoderPlace"
/>
</div>
@ -318,7 +318,7 @@ const REL_CSS = POPUP_CSS + `
<FlatLegend
v-else
:items="religionLegendItems"
title="Primary Religion"
:title="t('pplr.ui.primaryReligion')"
@select="onLegendSelect"
/>
@ -339,27 +339,27 @@ const REL_CSS = POPUP_CSS + `
<span class="toolbar-icon">{{ projection === 'globe' ? '🌐' : '🗺' }}</span>
</button>
<div class="basemap-wrap">
<button class="toolbar-btn round" title="Base map" @click="showBasemap = !showBasemap">
<button class="toolbar-btn round" :title="t('pplr.ui.baseMap')" @click="showBasemap = !showBasemap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M3 12l9 4 9-4M3 17l9 4 9-4" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showBasemap" class="basemap-panel">
<div class="basemap-title">Base map</div>
<div class="basemap-title">{{ t('pplr.ui.baseMap') }}</div>
<button v-for="b in BASEMAPS" :key="b.key" class="basemap-opt" :class="{ active: currentBasemap === b.key }" @click="setBasemap(b.key)">{{ b.label }}</button>
</div>
</div>
<!-- Data sources — moved into the toolbar to match ROP-tree (per user 2026-05-02) -->
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel toolbar-attached">
<div class="settings-header">Data sources</div>
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>

View file

@ -380,14 +380,14 @@ function popupHtml(p) {
return `<div class="pplr-popup">
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Affinity Bloc</td><td><b>${d(p.rop1)}</b></td></tr>
<tr><td>Cluster</td><td><b>${d(p.rop2)}</b></td></tr>
<tr><td>People Group</td><td><b>${d(p.rop3)}</b></td></tr>
<tr><td>${escHtml(t('pplr.popup.affinityBloc'))}</td><td><b>${d(p.rop1)}</b></td></tr>
<tr><td>${escHtml(t('pplr.popup.cluster'))}</td><td><b>${d(p.rop2)}</b></td></tr>
<tr><td>${escHtml(t('pplr.popup.peopleGroup'))}</td><td><b>${d(p.rop3)}</b></td></tr>
<tr><td>PEID</td><td><code>${d(p.peid)}</code></td></tr>
<tr><td>IMB PGID</td><td><code>${d(p.imb)}</code></td></tr>
<tr><td>JP Code</td><td><code>${d(p.jp)}</code></td></tr>
<tr><td>Religion</td><td>${d(p.religion)}</td></tr>
<tr><td>Language</td><td>${d(p.language)}</td></tr>
<tr><td>${escHtml(t('pplr.popup.religion'))}</td><td>${d(p.religion)}</td></tr>
<tr><td>${escHtml(t('pplr.popup.language'))}</td><td>${d(p.language)}</td></tr>
</table>
</div>`
}
@ -491,7 +491,7 @@ const TREE_POPUP_CSS = POPUP_CSS + `
:local-index="localIndex"
:kind-labels="KIND_LABELS"
:mapbox-token="mapboxToken"
placeholder="Search by people, place, language, religion…"
:placeholder="t('pplr.ui.search')"
@place="onGeocoderPlace"
/>
</div>
@ -508,13 +508,13 @@ const TREE_POPUP_CSS = POPUP_CSS + `
v-else
:nodes="ropTree"
:tabs="ROP_TABS"
title="ROP Hierarchy"
:title="t('pplr.ui.ropHierarchy')"
@select="onRopLegendSelect"
/>
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">ROP Hierarchy · Affinity Bloc → Cluster → People Group → PGIC</div>
<div class="header-pill">{{ t('pplr.pill.ropTree') }}</div>
<!-- Toolbar — rounded pill controls -->
<div class="map-toolbar">
@ -533,7 +533,7 @@ const TREE_POPUP_CSS = POPUP_CSS + `
<span class="toolbar-icon">{{ projection === 'globe' ? '🌐' : '🗺' }}</span>
</button>
<div class="basemap-wrap">
<button class="toolbar-btn round" title="Base map"
<button class="toolbar-btn round" :title="t('pplr.ui.baseMap')"
@click="showBasemap = !showBasemap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
@ -541,21 +541,21 @@ const TREE_POPUP_CSS = POPUP_CSS + `
</svg>
</button>
<div v-if="showBasemap" class="basemap-panel">
<div class="basemap-title">Base map</div>
<div class="basemap-title">{{ t('pplr.ui.baseMap') }}</div>
<button v-for="b in BASEMAPS" :key="b.key" class="basemap-opt"
:class="{ active: currentBasemap === b.key }"
@click="setBasemap(b.key)">{{ b.label }}</button>
</div>
</div>
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>

View file

@ -16,7 +16,7 @@ import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, loadOnce, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import { addCountryPolygons, loadOnce, popupForPG, POPUP_CSS, escHtml } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import DataSourcesDropdown from '../components/DataSourcesDropdown.vue'
@ -123,11 +123,11 @@ onMounted(async () => {
].filter(Boolean)
const rosettaRow = sample
? `<tr><td>Rosetta ★</td><td><b style="color:#d97706">★ sample PG</b></td></tr>`
? `<tr><td>Rosetta ★</td><td><b style="color:#d97706">${escHtml(t('pplr.rosetta.samplePg'))}</b></td></tr>`
: ''
const missingNote = missingIds.length
? `<tr><td colspan="2" style="padding-top:6px;font-size:10px;color:#6b7280;font-style:italic">
${missingIds.join(', ')} ID${missingIds.length > 1 ? 's' : ''} not yet cross-mapped in PPLR — empty rows are a data gap, not an error.
${escHtml(t('pplr.rosetta.missingIds', { ids: missingIds.join(', ') }))}
</td></tr>`
: ''
new (glFor(map.value)).Popup({ maxWidth: '360px' })
@ -154,11 +154,11 @@ onMounted(async () => {
onBeforeUnmount(() => destroy())
const LEGEND = [
{ color: '#fbbf24', label: 'JP + IMB + WCD all' },
{ color: '#fbbf24', label: t('pplr.rosetta.legend.all') },
{ color: '#3b82f6', label: 'JP + IMB' },
{ color: '#a78bfa', label: 'IMB only' },
{ color: '#ec4899', label: 'JP only' },
{ color: '#6b7280', label: 'neither' },
{ color: '#a78bfa', label: t('pplr.rosetta.legend.imbOnly') },
{ color: '#ec4899', label: t('pplr.rosetta.legend.jpOnly') },
{ color: '#6b7280', label: t('pplr.rosetta.legend.neither') },
]
</script>
@ -167,9 +167,9 @@ const LEGEND = [
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">Cross-system ID Rosetta · color = which IDs exist · ★ = sample row</div>
<div class="header-pill">{{ t('pplr.pill.rosetta') }}</div>
<div class="legend">
<div class="title">ID coverage</div>
<div class="title">{{ t('pplr.legendTitle.idCoverage') }}</div>
<div v-for="l in LEGEND" :key="l.label" class="row">
<span class="dot" :style="{ background: l.color }" />{{ l.label }}
</div>

View file

@ -65,6 +65,7 @@ import { DXLIFE_TOKENS } from './dxlife-tokens.js'
// labour country-poster-wall.vue states in its own header ("this profile supplies only its
// data and taxonomy").
import PosterWall from '@map/components/poster/PosterWall.vue'
import RegionSectionCard from '@map/components/regions/RegionSectionCard.vue'
import posterData from '@map/data/country-posters.json'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { onBeforeUnmount } from 'vue'
@ -426,74 +427,6 @@ useShadowStyles(
'.rl-summary{ margin:0; color:var(--dx-muted); font-size:var(--font-size-md); }' +
'.rl-status{ padding:var(--spacing-xl); text-align:center; color:var(--dx-muted); font-size:var(--font-size-md); }' +
'.rl-status.rl-failed{ font-weight:600; }' +
// .region-card: DX-life's --border-radius-xlg (35px), overflow hidden, white body, no border --
// the dark header band supplies its own edge, unlike our old grey-outlined card.
'.region-card{ flex:0 0 auto; border-radius:var(--dx-radius); overflow:hidden; background:var(--dx-elevated); }' +
// .region-card__header: padding = --spacing-md / clamp(--spacing-md,4vw,--spacing-2xl) -- the clamp
// is KEPT as a clamp (var(--spacing-md) / var(--spacing-2xl) are the resolved ends), not frozen at its desktop maximum.
// gap = --spacing-2xs (7.5px), on --color-surface-brand.
'.region-card__header{ display:flex; flex-direction:column; gap:var(--spacing-2xs);' +
' padding:var(--spacing-md) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl));' +
' background:var(--dx-header-bg); color:var(--dx-header-text); }' +
// .region-card__title: font-size clamp(--font-size-xl,4vw,--font-size-3xl) = clamp(var(--font-size-xl),_,var(--font-size-3xl)).
// A phone gets the var(--font-size-xl) FLOOR, as theirs does; it only reaches var(--font-size-3xl) past ~1192px of width.
// DX-life's heading font (Bebas Neue -- a bold condensed display face), regular weight (the
// face itself carries the boldness), all-caps to match the source screenshot exactly.
// Bebas Neue itself is DX-life's own self-hosted font file (doxa.life/assets/fonts/BebasNeue/),
// not served with CORS -- cross-origin @font-face silently fails without it, so it cannot be
// loaded here without shipping a font asset this repo doesn't own. Approximated with the
// standard condensed-display-face fallback stack (bold weight + font-stretch:condensed + tight
// tracking), since Bebas Neue's glyphs read as heavy/compressed even at its nominal "400" weight.
// getComputedStyle() on the live card title reads weight 400 / line-height 1.0 (47.68px on a
// 47.69px font-size) / letter-spacing normal / text-transform none -- Bebas Neue's own glyphs
// are what read as bold+condensed+caps-like, DX-life applies NO transform on top of the font.
// Our fallback stack has no true Bebas-Neue substitute, so font-stretch:condensed stands in for
// the face's natural narrowness, but weight/leading/tracking/case now match the measured values
// exactly rather than compounding extra "boldness" the source doesn't have.
'.region-card__title{ margin:0; font-size:clamp(var(--font-size-xl), 4cqw, var(--font-size-3xl)); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; letter-spacing:normal; }' +
// Their link is an <a class="region-card__link | with-icon">, so it carries their `a` rule's
// font-weight: var(--font-weight-medium) = 500 (_globals.scss:132). Ours is a <button>
// because this element has no router, and `font:inherit` gave it 400 — MEASURED against
// their live /regions. Same class of miss as the dashboard back-link.
// The icon spacing is theirs too: .with-icon is inline-flex with align-items:baseline and NO
// gap (composition/_icon.scss:12), and the 1rem separation comes from
// `.with-icon .icon.right { margin-inline-start: 1rem }` — 10px at their 10px root. We had
// the same 10px via `gap`, which looks identical but means their rule would not apply, so
// the mechanism is now theirs: margin on the icon, gap left alone.
// ★ text-align:START, not left. Theirs is a NuxtLink, an inline <a> that shrink-wraps to its
// text; ours must be a <button> because this element changes the embed's subject in place instead
// of navigating a route. A button's UA default is text-align:center, so the port overrode it -- but
// it overrode it with the PHYSICAL `left`, which is identical to `start` in English and its
// opposite in Arabic. MEASURED on live /ar/regions vs ours at dir=rtl: same 246.9px row and the
// same 102.7px of text, their label flush to the row's RIGHT edge (gap 0px, the RTL reading start)
// and ours flush LEFT -- every label stranded 144.2px on the wrong side. Invisible in English,
// which is exactly why it survived a 37-property card diff and an 11-property bullet diff.
'.region-card__link{ appearance:none; background:none; border:0; padding:0; margin:0; text-align:start;' +
' cursor:pointer; color:inherit; font:inherit; font-weight:500;' +
' display:inline-flex; align-items:baseline; }' +
'.region-card__link:hover{ color:#92b195; }' +
'.region-card__link:focus-visible{ outline:2px solid #fff; outline-offset:2px; }' +
// .region-card__icon: DX-life's own <svg class="icon | rotate-90 right"><use href="arrow-
// chevron.svg#chevron-up"></use></svg> -- a REAL chevron path (viewBox 0 0 489.67 289.877),
// not a text glyph. Ported verbatim (fill:currentColor tracks the surrounding text colour,
// same as their `.icon{ fill:currentColor }` utility) and rotated 90deg to point right, same
// as their `rotate-90` class. getComputedStyle measured it at 33.38px square on the live page.
// ★ 1rem WAS THE ONE UNIT IN THIS PORT THAT INHERITS THE HOST PAGE'S ROOT, and it was wrong on
// our own leaf, not only in an embed. `rem` resolves against the DOCUMENT root wherever the
// element lives — which is the whole reason dxlife-tokens.js resolves their scale to px — and
// their root is 10px (html{font-size: var(--font-size-base)}, $font-size-base: 10px). Ours
// inherits whatever the host sets: 16px by default, so this gap rendered 16px against their
// 10px, and on a host with the common html{font-size:62.5%} reset it would render 10px by
// accident. Pinned to the value their root produces, like every other token here.
// No diff caught it: the list diff's `link` spec reads colour, decoration, weight, display and
// alignItems — no margin — so the icon's own spacing was never a measured property. It is now.
// (Credit where due: the class of bug came to me as a flock relay from another agent's sweep.)
'.region-card__icon{ width:0.7em; height:0.7em; flex:none; fill:currentColor; transform:rotate(90deg);' +
' margin-inline-start:10px; }' +
// .region-card__summary: --font-size-md (var(--font-size-md)), 0.85 opacity, on the header band.
'.region-card__summary{ font-size:var(--font-size-md); opacity:0.85; margin:0; }' +
// ★ 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
@ -505,81 +438,6 @@ useShadowStyles(
// 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"] .rl-title, [lang="ru-RU"] .rl-title,' +
'[lang="ru"] .region-card__title, [lang="ru-RU"] .region-card__title{ font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' letter-spacing:-0.02em; }' +
// .region-card__body: padding --spacing-lg / clamp(...) / --spacing-xl -> var(--spacing-lg) / clamp / var(--spacing-xl)
'.region-card__body{ padding:var(--spacing-lg) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-xl); }' +
// .countries-grid: DX-life's track is minmax(220px,1fr). Gap --spacing-xs / --spacing-xl
// (10px / var(--spacing-xl)). getComputedStyle on the live grid shows padding-left:35.16px -- that's the
// page's global `ul` reset (bullets kept, not list-style:none), so the whole grid is indented
// one list-item's worth even though DX-life's countries-grid a rule doesn't set it directly.
// Reproduced with list-style:none + the same 35.16px indent kept explicitly so a real list
// bullet has somewhere to sit without stealing width from the text column.
// padding-INLINE-start, as theirs is (1.8em). The physical `padding-left:35.2px` it replaces
// put the indent on the wrong side in Arabic, where the bullets hang off the right edge.
'.countries-grid{ list-style:none; margin:0; padding-inline-start:1.8em; display:grid;' +
' grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px var(--spacing-xl); }' +
// Plain inline text (not a flex row) so multi-line country names wrap the same way DX-life's
// <NuxtLink> text does -- a flex row with align-items:baseline pinned the bullet to the first
// line only and stranded the (count) span beside the second line on wrap. font-weight:500
// matches getComputedStyle() on the live country links (theirs is medium, not regular).
// Their country entries are bare <NuxtLink>s inside .countries-grid li — no class of their
// own — so they take their `a` rule: color var(--color-brand-light) = $color-green-400 =
// #4e594f, font-weight medium. MEASURED on their live /regions: rgb(78, 89, 79). Ours used
// var(--dx-text), which resolves to rgb(42, 51, 42) — the body ink, not their link ink.
'.country-link{ appearance:none; background:none; border:0; padding:2px 0; margin:0;' +
' text-align:start; cursor:pointer; color:#4e594f; font:inherit; font-size:var(--font-size-md);' +
' font-weight:500; width:100%; position:relative; }' +
// inset-inline-start, NOT left: the bullet hangs off the text's starting edge, which is the
// RIGHT edge in Arabic. With `left:-16px` it stayed on the physical left in RTL — measured
// at locale=ar, computed right was 256.938px instead of the -16px near-edge overhang.
// ★ THEIR BULLET, COPIED — not a dot of my own. base/_globals.scss:117-131 paints the list
// marker with a 16x16 data-URI SVG circle in #92b195 sized to 1.5em, hung off the text's start
// edge by margin-inline-start:-1.3em (-2em in RTL), top .3em, line-height .9. Their
// .countries-grid comment is explicit that keeping it is deliberate ("Keeps the site's list
// bullets (global ul padding + ::before dot)").
// I had re-created it as a 6px border-radius:50% square, and MEASURED against live it was wrong
// in 9 of 11 properties: 6px vs 29.2969px box, solid fill vs a transparent box carrying the
// image, top 11.72px vs 5.86px, and positioned with inset-inline-start:-16px instead of their
// negative margin. Not a near-miss — a different bullet.
// The rule is a double-quoted JS string because the data URI contains single quotes.
".country-link::before{ content:''; position:absolute; top:0.3em; width:1.5em; height:1.5em;" +
" line-height:0.9; margin-inline-start:-1.3em; background-repeat:no-repeat;" +
" background-image:url(\"data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='%2392b195' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='8' cy='8' r='7' stroke='%2392b195' stroke-width='1.5'/%3E%3C/svg%3E\"); }" +
// Their RTL variant. It is a separate rule here rather than a nested [dir="rtl"] & because this
// CSS lives in a shadow root, where a [dir] selector on an outer ancestor does not reach in.
'[dir="rtl"] .country-link::before{ margin-inline-start:-2em; }' +
'.country-link:hover{ text-decoration:underline; text-decoration-color:#92b195; }' +
'.country-link:focus-visible{ outline:2px solid #92b195; outline-offset:2px; border-radius:4px; }' +
'.country-link__count{ color:var(--dx-muted); }' +
// .region-card__footer: DX-life's exact rule -- flex, flex-end, margin-top --spacing-lg (var(--spacing-lg)),
// NO margin-bottom. This element is a <p>; its user-agent default margin-block-end (~var(--font-size-md) at
// this font-size) was leaking through because only margin-top was ever set -- pin margin:0
// first, then apply margin-top, so the bottom UA default can't survive the cascade.
// The toggle row and the wall it opens. The caret rotates rather than swapping glyphs,
// which is what makes open/closed legible at a glance.
'.region-card__posters-toggle{ margin-top:0; }' +
'.region-card__icon--open{ transform:rotate(180deg); }' +
// RTL MIRRORING, ported from their utilities/_shapes.scss:
// .rotate-90 { transform:rotate(90deg); [dir="rtl"] & { transform:rotate(-90deg); } }
// Their .rotate-180 has NO rtl variant, and that distinction is the whole rule: a chevron that
// means "forward" must mirror, a disclosure caret that means "open/closed" must not. MEASURED
// on live doxa.life: .region-card__link svg is matrix(0,1,-1,0,0,0) at /regions and
// matrix(0,-1,1,0,0,0) at /ar/regions — ours stayed at +90deg in Arabic, so the chevron sat on
// the correct side pointing the WRONG WAY. Found by looking at the Arabic screenshot.
// The selector works inside our shadow root because .rl-root carries :dir itself.
'[dir="rtl"] .region-card__icon{ transform:rotate(-90deg); }' +
// ...and this one is load-bearing: [dir="rtl"] .region-card__icon is specificity 0-2-0, which
// would otherwise beat the 0-1-0 --open modifier and freeze the poster caret in Arabic.
'[dir="rtl"] .region-card__icon--open{ transform:rotate(180deg); }' +
'.region-card__posters{ margin-top:13.3px; }' +
'.region-card__footer{ display:flex; justify-content:flex-end; margin:0; margin-top:var(--spacing-lg);' +
' padding:0 clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-lg); }' +
'.region-card__footer-link{ appearance:none; background:none; border:0; padding:0; margin:0;' +
' cursor:pointer; font:inherit; font-size:var(--font-size-md); font-weight:500; color:var(--dx-link);' +
' display:inline-flex; align-items:baseline; gap:10px; text-decoration:underline; text-decoration-thickness:2px; }' +
'.region-card__footer-link:hover{ color:var(--dx-header-bg); }' +
'.region-card__footer-link:focus-visible{ outline:2px solid var(--dx-link); outline-offset:2px; border-radius:4px; }',
'regions-list'
)
</script>
@ -625,96 +483,21 @@ useShadowStyles(
<div v-else-if="!regions.length" class="rl-status">{{ t('No regions found.') }}</div>
<template v-else>
<div
<RegionSectionCard
v-for="region in regions"
:key="region.slug"
class="region-card"
:title="region.label || 'Other'"
:summary="summaryLine(region)"
:countries="region.countries"
:footer-label="dashboardLink(region)"
:disclosure-label="postersOpen(region) ? t('Hide country posters') : t('View country posters')"
:disclosure-open="postersOpen(region)"
:disclosure-id="`posters-${region.slug}`"
@toggle-disclosure="togglePosters(region)"
@select="selectRegion(region)"
@select-country="(c) => selectCountry(c, region)"
>
<div class="region-card__header">
<h2 class="region-card__title">
<button
type="button"
class="region-card__link"
@click="selectRegion(region)"
>
{{ region.label || 'Other' }}
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
<use href="#chevron-up" />
</svg>
</button>
</h2>
<p class="region-card__summary">{{ summaryLine(region) }}</p>
</div>
<div class="region-card__body">
<ul class="countries-grid">
<li v-for="c in region.countries" :key="c.slug">
<button
type="button"
class="country-link"
@click="selectCountry(c, region)"
>
{{ c.name }} <span class="country-link__count">({{ c.count }})</span>
</button>
</li>
</ul>
</div>
<p class="region-card__footer">
<button
type="button"
class="region-card__footer-link"
@click="selectRegion(region)"
>
{{ dashboardLink(region) }}
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
<use href="#chevron-up" />
</svg>
</button>
</p>
<!-- THE POSTER TOGGLE — Driver 2026-09-24: "a toggle or carrot added to the bottom
of the region section that says view country posters, which will let you view
country posters for each of the countries in the regions". The caret is the
same SVG the header and footer links already use, rotated when open, so the
control reads as part of this card rather than bolted on. aria-expanded and
aria-controls are what make it a real disclosure for a screen reader. -->
<p class="region-card__footer region-card__posters-toggle">
<button
type="button"
class="region-card__footer-link"
:aria-expanded="postersOpen(region) ? 'true' : 'false'"
:aria-controls="`posters-${region.slug}`"
@click="togglePosters(region)"
>
{{ postersOpen(region) ? t('Hide country posters') : t('View country posters') }}
<svg
class="region-card__icon"
:class="{ 'region-card__icon--open': postersOpen(region) }"
viewBox="0 0 489.67 289.877"
aria-hidden="true"
>
<use href="#chevron-up" />
</svg>
</button>
</p>
<!-- The wall itself: the SHARED component, given only this region's countries. No
second level of collapsing (we are already inside one region) and no grouping,
hence posterGroupBy returning ''. -->
<div v-if="postersOpen(region)" :id="`posters-${region.slug}`" class="region-card__posters">
<!-- poster-action="print" — PRINT IN PLACE, not download (Driver card 2026-09-24).
PosterWall's posterAction prop defaults to 'download'; UPG-100 passes "print"
and the print UX here must be identical. deliverPoster() branches on that one
prop and routes BOTH the shipped-.webp and the browser-rasterised path through
openPrintFrame, so this is configuration — not a second implementation. -->
<!-- ★ TWO PER ROW, AND A KNOB. The Driver, verbatim: "i need two posters per row on
the regiosn posters and witha setitn got icnreat eh number of poswers per row".
The wall's own default is an auto-fill grid at minmax(210px,1fr), which lays out
five across a region card; two is a deliberate, larger card, and the control lets
a reader put it back.
Both props are OPT-IN on the shared component, so country-posters and
my-upg-100-list keep the auto-fill they have always had.
The comment sits ABOVE the tag because an HTML comment inside an attribute list is
a parse error — which is how the first attempt at this failed. -->
<template #extra>
<PosterWall
:items="postersFor(region)"
:group-by="posterGroupBy"
@ -736,8 +519,8 @@ useShadowStyles(
poster-action="print"
prayer-list-soon-message="Coming soon — a prayer list will be generated for each country, naming every unengaged people group within it."
/>
</div>
</div>
</template>
</RegionSectionCard>
</template>
</div>
</div>

View file

@ -0,0 +1,62 @@
#!/usr/bin/env node
/* check-regions-uses-library.mjs — `bun run check:regions-uses-library`. Source-only, headless.
*
* THE DRIVER'S POINT, and the reason this check exists rather than a note: his negotiating position
* with the DX-life author is that cloning their /regions page into this bundler MADE ITS COMPONENTS
* REUSABLE for every future map plugin. He named the failure mode himself — "a load bearing assumption
* that could be proven wrong by going into the private plugin folder for the Regions plugin and seeing
* that actually the components are not reusable".
*
* So this asserts the thing that can be checked by opening the folder: the regions plugin must USE the
* shared components, and must NOT hold its own copy of what it uses. A claim about reusability that
* only a human can audit is a claim that rots; this one fails on the day someone re-inlines a copy.
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
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
const EXTRACTED = [
{
lib: 'library/components/regions/RegionSectionCard.vue',
consumer: 'plugin-designs/regions-dashboard/regions-list.vue',
tag: 'RegionSectionCard',
owns: ['region-card', 'countries-grid', 'country-link'],
},
]
for (const e of EXTRACTED) {
const name = e.lib.split('/').pop()
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(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.
const tpl = plugin.slice(plugin.indexOf('<template>'))
for (const cls of e.owns) {
ok(!new RegExp(`class="[^"]*\\b${cls}\\b`).test(tpl),
`the plugin's own template no longer uses .${cls} — the library component owns that markup`)
ok(!new RegExp(`'\\.${cls}[{ ]`).test(plugin),
`and no longer ships CSS for .${cls} — one copy, in the library component`)
}
// ...and the library component must actually carry the design, or "reusable" means markup only.
const lib = read(e.lib)
for (const cls of e.owns) {
ok(new RegExp(`'\\.${cls}[{ ]`).test(lib), `${name} DOES carry .${cls} styling, so a consumer gets the look`)
}
}
// 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')
ok(!/RegionSectionCard/.test(read('plugin-designs/regions-dashboard/region-dashboards.vue')),
'CONTROL: the OTHER tab does not use the card yet — this check is about what is extracted, not a wish')
console.log(fails ? `\nREGIONS-USES-LIBRARY: ${fails} FAIL(s)` : '\nREGIONS-USES-LIBRARY: the plugin consumes the shared component and keeps no copy of it')
process.exit(fails ? 1 : 0)

View file

@ -274,7 +274,7 @@ function addLayers() {
// Add EMPTY first, then setData after the fetch: the addLayer calls below must run
// SYNCHRONOUSLY to keep these fills beneath the pins (layer order is insertion order).
m.addSource(SRC_COUNTRIES, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
fetch(`${resolveAssetsBase(profileConfig?.value)}/boundaries/world-adm0.geojson`)
fetch(`${resolveAssetsBase(profileConfig?.value)}/boundaries/world-adm0-web.geojson`)
.then((r) => r.json())
.then((gj) => {
for (const f of gj.features || []) {

View file

@ -834,7 +834,7 @@ function addLayers() {
// keep this layer underneath the pins (layer order is insertion order). An awaited fetch
// here would put the choropleth on TOP of every bubble.
m.addSource('rd-countries', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
fetch(`${resolveAssetsBase(profileConfig.value)}/boundaries/world-adm0.geojson`)
fetch(`${resolveAssetsBase(profileConfig.value)}/boundaries/world-adm0-web.geojson`)
.then((r) => r.json())
.then((gj) => {
for (const f of gj.features || []) {

View file

@ -146,8 +146,8 @@ useShadowStyles(`
<input class="cpt-search" type="search" :value="store.search.value" @input="onSearch"
:placeholder="$t('neighbor.searchCockpit')" :aria-label="$t('neighbor.search')" />
<span class="cpt-counter">
<b>{{ store.counts.value.senders.toLocaleString() }}</b> senders →
<b>{{ store.counts.value.clusters.toLocaleString() }}</b> clusters · {{ store.counts.value.hidden }} hidden
<b>{{ store.counts.value.senders.toLocaleString() }}</b> {{ $t('neighbor.cockpit.senders') }}
<b>{{ store.counts.value.clusters.toLocaleString() }}</b> {{ $t('neighbor.cockpit.clustersHidden', { n: store.counts.value.hidden }) }}
</span>
<span class="cpt-prov" :class="{ err: store.provenance.value.apiError }"
:title="store.provenance.value.reachedFilter">
@ -163,8 +163,8 @@ useShadowStyles(`
<div class="cpt-cell map"><MapCell /></div>
<div class="cpt-cell flower"><FlowerCell /></div>
<div class="cpt-seg" role="tablist" :aria-label="$t('neighbor.cockpit.viewSwitch')">
<button :class="{ on: store.mode.value === 'map' }" @click="setMode('map')">MAP</button>
<button :class="{ on: store.mode.value === 'flower' }" @click="setMode('flower')">FLOWER</button>
<button :class="{ on: store.mode.value === 'map' }" @click="setMode('map')">{{ $t('neighbor.cockpit.map') }}</button>
<button :class="{ on: store.mode.value === 'flower' }" @click="setMode('flower')">{{ $t('neighbor.cockpit.flower') }}</button>
</div>
</div>
@ -177,13 +177,13 @@ useShadowStyles(`
<!-- LEGEND RIBBON (one shared key for all four cells) -->
<div class="cpt-legend">
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.RED }"></span> {{ $t('neighbor.cockpit.unreachedTarget') }}</span>
<span class="cpt-lg">reached %
<span class="cpt-lg">{{ $t('neighbor.strip.pctReached') }}
<span class="cpt-ramp">
<i v-for="(g, i) in COLORS.GREEN_BINS" :key="i" :style="{ background: g }"></i>
</span>
</span>
<span class="cpt-lg"><span class="cpt-line" :style="{ borderColor: COLORS.NAVY }"></span> SEND (solid=same-lang · dashed=cluster · dotted=in-country)</span>
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.GOLD }"></span> selection · pop = log radius · Luke 10:2</span>
<span class="cpt-lg"><span class="cpt-line" :style="{ borderColor: COLORS.NAVY }"></span> {{ $t('neighbor.cockpit.legendSend') }}</span>
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.GOLD }"></span> {{ $t('neighbor.cockpit.legendSelection') }}</span>
</div>
<!-- loading / error overlay -->

View file

@ -395,8 +395,8 @@ useShadowStyles(
<!-- ring legend -->
<div v-if="placed" class="cf-rings">
<b>inner</b> same-country · <b>petals</b> cross-border<br>
navy = SEND · <span :style="{ color: COLORS.GOLD }">gold</span> = same-language mesh
{{ $t('neighbor.fm.whorl') }}<br>
{{ $t('neighbor.cockpit.navySend') }} · <span :style="{ color: COLORS.GOLD }">{{ $t('neighbor.cockpit.goldMesh') }}</span>
</div>
</div>

View file

@ -328,9 +328,9 @@ useShadowStyles(`
`, 'flower-clusters')
const MODES = [
{ key: 'family', label: 'Family', sz: 'fewer · bigger' },
{ key: 'language', label: 'Language', sz: 'more' },
{ key: 'dialect', label: 'Dialect', sz: 'most' }
{ key: 'family', tKey: 'neighbor.fc.mode.family', szKey: 'neighbor.fc.size.fewerBigger' },
{ key: 'language', tKey: 'neighbor.fc.mode.language', szKey: 'neighbor.fc.size.more' },
{ key: 'dialect', tKey: 'neighbor.fc.mode.dialect', szKey: 'neighbor.fc.size.most' }
]
</script>
@ -343,9 +343,9 @@ const MODES = [
<div class="fc-modes" role="tablist" :aria-label="$t('neighbor.fc.clusterBy')">
<button v-for="md in MODES" :key="md.key" class="fc-mode" :class="{ on: mode === md.key }"
role="tab" :aria-selected="mode === md.key" @click="setMode(md.key)">
<span class="fc-circle">{{ md.label.slice(0,3).toUpperCase() }}</span>
<span class="fc-mode-lbl">{{ md.label }}</span>
<span class="fc-mode-sz">{{ md.sz }}</span>
<span class="fc-circle">{{ md.key.slice(0,3).toUpperCase() }}</span>
<span class="fc-mode-lbl">{{ $t(md.tKey) }}</span>
<span class="fc-mode-sz">{{ $t(md.szKey) }}</span>
</button>
</div>

View file

@ -63,12 +63,12 @@ const hiddenCount = computed(() => counts.value.hidden || 0)
// ── sort options ──────────────────────────────────────────────────────────────
const SORTS = [
{ key: 'mobilization', label: 'Mobilization' },
{ key: 'pct', label: '%Christian' },
{ key: 'pop', label: 'Population' },
{ key: 'bridge', label: 'Bridge' },
{ key: 'name', label: 'Name' },
{ key: 'frontier', label: 'Frontier first' }
{ key: 'mobilization', tKey: 'neighbor.list.sortMobilization' },
{ key: 'pct', tKey: 'neighbor.list.colPctChristian' },
{ key: 'pop', tKey: 'neighbor.list.colPopulation' },
{ key: 'bridge', tKey: 'neighbor.strip.bridgeType' },
{ key: 'name', tKey: 'neighbor.list.colName' },
{ key: 'frontier', tKey: 'neighbor.list.sortFrontier' }
]
function pickSort(key) { store.setSort(key) }
@ -303,7 +303,7 @@ useShadowStyles(`
:class="{ active: sortKey === s.key, frontier: s.key === 'frontier' }"
type="button"
@click="pickSort(s.key)"
>{{ s.label }}</button>
>{{ $t(s.tKey) }}</button>
</div>
</div>
@ -363,7 +363,7 @@ useShadowStyles(`
class="cpt-l-info"
:class="{ on: pinnedInfo === r.id }"
type="button"
title="Details"
:title="$t('neighbor.cockpit.details')"
@click.stop="toggleInfo(r.id)"
>i</button>
</div>
@ -374,12 +374,12 @@ useShadowStyles(`
<dt>{{ $t('neighbor.list.colPeopleGroup') }}</dt><dd>{{ r.name }}</dd>
<dt>{{ $t('neighbor.list.colCountry') }}</dt><dd>{{ r.country }}</dd>
<dt>{{ $t('neighbor.list.colLanguage') }}</dt><dd>{{ r.lang }}<span v-if="r.rol3"> · {{ r.rol3 }}</span></dd>
<dt>%Christian</dt><dd>{{ pctLabel(r.pct) }} · {{ binLabel(r.pct) }}</dd>
<dt>{{ $t('neighbor.list.colPctChristian') }}</dt><dd>{{ pctLabel(r.pct) }} · {{ binLabel(r.pct) }}</dd>
<dt>{{ $t('neighbor.list.colPopulation') }}</dt><dd>{{ fmtPop(r.pop) }}</dd>
<dt>Bridge</dt><dd>{{ r.bridge }}</dd>
<dt>Adjacency</dt><dd>{{ r.adjacency }}</dd>
<dt>{{ $t('neighbor.strip.bridgeType') }}</dt><dd>{{ r.bridge }}</dd>
<dt>{{ $t('neighbor.list.colAdjacency') }}</dt><dd>{{ r.adjacency }}</dd>
<dt>{{ $t('neighbor.list.colReligion') }}</dt><dd>{{ r.religion_bg || r.religionFamily }}</dd>
<dt v-if="r.code">Code</dt><dd v-if="r.code">{{ r.code }}</dd>
<dt v-if="r.code">{{ $t('neighbor.list.colCode') }}</dt><dd v-if="r.code">{{ r.code }}</dd>
</dl>
</div>
</div>

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-JoGdbQlwjgq9+dOQy3eDJ1DjLwyZ6VbclEci++67/f8faF0HOa1ox10mkNn1fj73" crossorigin="anonymous"></script>
<script src="../all-people-groups.js" integrity="sha384-3rrykWo456uGE2YmJ/i7AeehqxCWQe0bVDjDOj5VEwaPZV5pFw4iegauoNaZMyDR" 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

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,7 @@
"meta": {
"title": "Messaging & App Channels",
"grain": "one row per country x communication app",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp is the top messaging app in 54 countries, Telegram in 38 — neither appears in referral share at all.",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp (Messenger or Business) is the top messaging app in 73 countries, Telegram in 38 — neither appears in referral share at all.",
"ios_caveat": "iOS ONLY. Most of these countries are Android-majority and Google publishes no keyless equivalent, so this is one eye open. An app absent here is NOT absent in-country. 50 of 175 countries have no Apple storefront.",
"ranking_caveat": "A top-free RANK is most-DOWNLOADED recently, not most-used.",
"classification_caveat": "kind is a name-keyword HEURISTIC over a known list, not a verified taxonomy.",
@ -2830,19 +2830,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Thailand",
"iso3": "THA",
"region": "Asia",
"app": "LINE MAN: Food Delivery & more",
"kind": "messaging",
"rank": 13,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Burkina Faso",
"iso3": "BFA",
@ -3006,7 +2993,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 37,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
@ -7809,19 +7796,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Maldives",
"iso3": "MDV",
"region": "Asia",
"app": "Malé Taxi Line",
"kind": "messaging",
"rank": 1,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Northern Mariana Islands",
"iso3": "MNP",
@ -13120,7 +13094,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 33,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",

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-Yyfio5ozzXMi/atFe+yX9p275iNd8nHRt72eiac+wqcE7pDa4ZJdOWPdb69snAkV" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO" 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-Yyfio5ozzXMi/atFe+yX9p275iNd8nHRt72eiac+wqcE7pDa4ZJdOWPdb69snAkV" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO" 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-Yyfio5ozzXMi/atFe+yX9p275iNd8nHRt72eiac+wqcE7pDa4ZJdOWPdb69snAkV" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO" 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-xwzODwX387yfNONPEKpKzvyroMMl53Q2sNDSad+GSend5mDN4FG4BWR0OIQNjdty" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo" 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-xwzODwX387yfNONPEKpKzvyroMMl53Q2sNDSad+GSend5mDN4FG4BWR0OIQNjdty" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo" 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-xwzODwX387yfNONPEKpKzvyroMMl53Q2sNDSad+GSend5mDN4FG4BWR0OIQNjdty" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo" 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-sJSAqY3fTSJPU1Bh06c1uEMUr/CWR6qMbtJ5UgCjVc7nd0W+pNXJcvYy80GzlLsS" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-PX/Ip+YNyNzpLKKUMO7ud3cQRKx39yp5Xom4+y/HgK5x4JhtEdJnaamoNo4ooEPI" 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-sJSAqY3fTSJPU1Bh06c1uEMUr/CWR6qMbtJ5UgCjVc7nd0W+pNXJcvYy80GzlLsS" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-PX/Ip+YNyNzpLKKUMO7ud3cQRKx39yp5Xom4+y/HgK5x4JhtEdJnaamoNo4ooEPI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

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