Merge remote-tracking branch 'origin/main' into rega/research-gallery-embed

# Conflicts:
#	package.json
#	public/doxa-maps/index.html
This commit is contained in:
doxa-maps-88655c70 2026-09-25 07:22:18 +00:00
commit b32795aa0e
236 changed files with 16411 additions and 1923 deletions

View file

@ -2,7 +2,7 @@
Made by Vibe Coders for Vibe Coders to prepare the way of the Lord and make His paths straight.
Isaiah 40:3 “...Prepare the way for the LORD in the wilderness; make a straight highway for our God in the desert."
**Isaiah 40:3 “...Prepare the way for the LORD in the wilderness; make a straight highway for our God in the desert."**
# dx-maps — one build, maps that embed everywhere

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,40 @@
{
"sources": {
"imb": {
"file": "data/people-groups/people_groups.csv",
"publisher": "IMB / peoplegroups.org",
"captured": "2026-07-16"
},
"doxa": {
"file": "data/people-groups/doxa-groups-snapshot.json",
"source": "https://pray.doxa.life/api/people-groups/list",
"fetched": "2026-09-25"
}
},
"steps": [
{
"id": "all",
"groups": 12379,
"population": 8288563969
},
{
"id": "unreached",
"groups": 7341,
"population": 4893057899
},
{
"id": "frontier",
"groups": 4754,
"population": 2813855464
},
{
"id": "doxa",
"groups": 2107,
"population": 209079159,
"frontier": 1745,
"unreached": 2082,
"matched": 2091,
"unmatched": 16
}
]
}

View file

@ -70,8 +70,8 @@
"title": "The gap by country",
"grain": "one row per country that has at least one people group on this map",
"generated": "2026-09-25",
"measured": "242 countries · 1,613 groups with nothing found · 207,867,995 people",
"by_region": "Oceania 30.1% (399 of 1325) · Africa 14.2% (438 of 3074) · Asia 11.3% (575 of 5073) · Europe 8.9% (70 of 790) · Americas 6.2% (131 of 2117)",
"measured": "242 countries · 1,592 groups with nothing found · 205,861,745 people",
"by_region": "Oceania 30.1% (399 of 1325) · Africa 14.1% (433 of 3074) · Asia 11.0% (560 of 5073) · Europe 8.9% (70 of 790) · Americas 6.1% (130 of 2117)",
"three_questions": "Groups with nothing, people with nothing, and the share of the country each represents. They rank differently on purpose — Papua New Guinea has the most groups and few people, China the reverse, and Oceania has the worst share of any region while being the smallest by population.",
"source": "data/tables/gospel-by-country.json, which is itself refreshed from data/tables/people-group-pins.json — the same index the map draws, so this table and the map cannot disagree.",
"curated_columns": "growing_unreached and deaths_day come from the curated country analysis and are null where it does not cover the country. Null, never zero: zero is a claim this build cannot make."
@ -112,9 +112,9 @@
"iso3": "CHN",
"region": "Asia",
"people_groups": 391,
"groups_with_nothing": 83,
"pct_with_nothing": 21.2,
"people_with_nothing": 127348300,
"groups_with_nothing": 76,
"pct_with_nothing": 19.4,
"people_with_nothing": 125832700,
"groups_with_scripture": 118,
"groups_with_film": 227,
"band": "partial",
@ -182,21 +182,6 @@
"deaths_day": 44,
"basis": "pins, with curated country analysis"
},
{
"country": "Lao People's Democratic Republic",
"iso3": "LAO",
"region": "Asia",
"people_groups": 158,
"groups_with_nothing": 45,
"pct_with_nothing": 28.5,
"people_with_nothing": 692685,
"groups_with_scripture": 55,
"groups_with_film": 58,
"band": "partial",
"growing_unreached": 0,
"deaths_day": 15.5,
"basis": "pins, with curated country analysis"
},
{
"country": "Vanuatu",
"iso3": "VUT",
@ -212,21 +197,6 @@
"deaths_day": 0,
"basis": "pins, with curated country analysis"
},
{
"country": "Sudan",
"iso3": "SDN",
"region": "Africa",
"people_groups": 185,
"groups_with_nothing": 42,
"pct_with_nothing": 22.7,
"people_with_nothing": 2414100,
"groups_with_scripture": 96,
"groups_with_film": 117,
"band": "partial",
"growing_unreached": 36,
"deaths_day": 359.6,
"basis": "pins, with curated country analysis"
},
{
"country": "Chad",
"iso3": "TCD",
@ -242,6 +212,36 @@
"deaths_day": 68.4,
"basis": "pins, with curated country analysis"
},
{
"country": "Sudan",
"iso3": "SDN",
"region": "Africa",
"people_groups": 185,
"groups_with_nothing": 39,
"pct_with_nothing": 21.1,
"people_with_nothing": 2280200,
"groups_with_scripture": 96,
"groups_with_film": 117,
"band": "partial",
"growing_unreached": 36,
"deaths_day": 359.6,
"basis": "pins, with curated country analysis"
},
{
"country": "Lao People's Democratic Republic",
"iso3": "LAO",
"region": "Asia",
"people_groups": 158,
"groups_with_nothing": 38,
"pct_with_nothing": 24.1,
"people_with_nothing": 553985,
"groups_with_scripture": 55,
"groups_with_film": 58,
"band": "partial",
"growing_unreached": 0,
"deaths_day": 15.5,
"basis": "pins, with curated country analysis"
},
{
"country": "Brazil",
"iso3": "BRA",
@ -287,21 +287,6 @@
"deaths_day": 0.1,
"basis": "pins, with curated country analysis"
},
{
"country": "United States of America",
"iso3": "USA",
"region": "Americas",
"people_groups": 623,
"groups_with_nothing": 18,
"pct_with_nothing": 2.9,
"people_with_nothing": 322725,
"groups_with_scripture": 517,
"groups_with_film": 549,
"band": "partial",
"growing_unreached": 0,
"deaths_day": 0.1,
"basis": "pins, with curated country analysis"
},
{
"country": "Vietnam",
"iso3": "VNM",
@ -317,6 +302,21 @@
"deaths_day": 5.7,
"basis": "pins, with curated country analysis"
},
{
"country": "United States of America",
"iso3": "USA",
"region": "Americas",
"people_groups": 623,
"groups_with_nothing": 17,
"pct_with_nothing": 2.7,
"people_with_nothing": 285225,
"groups_with_scripture": 517,
"groups_with_film": 549,
"band": "partial",
"growing_unreached": 0,
"deaths_day": 0.1,
"basis": "pins, with curated country analysis"
},
{
"country": "Russian Federation",
"iso3": "RUS",
@ -902,21 +902,6 @@
"deaths_day": 2.8,
"basis": "pins, with curated country analysis"
},
{
"country": "Guinea",
"iso3": "GIN",
"region": "Africa",
"people_groups": 41,
"groups_with_nothing": 4,
"pct_with_nothing": 9.8,
"people_with_nothing": 146600,
"groups_with_scripture": 19,
"groups_with_film": 23,
"band": "partial",
"growing_unreached": 7,
"deaths_day": 14.5,
"basis": "pins, with curated country analysis"
},
{
"country": "Equatorial Guinea",
"iso3": "GNQ",
@ -1082,6 +1067,21 @@
"deaths_day": 0,
"basis": "pins, with curated country analysis"
},
{
"country": "Guinea",
"iso3": "GIN",
"region": "Africa",
"people_groups": 41,
"groups_with_nothing": 3,
"pct_with_nothing": 7.3,
"people_with_nothing": 139000,
"groups_with_scripture": 19,
"groups_with_film": 23,
"band": "partial",
"growing_unreached": 7,
"deaths_day": 14.5,
"basis": "pins, with curated country analysis"
},
{
"country": "Eritrea",
"iso3": "ERI",
@ -1112,21 +1112,6 @@
"deaths_day": null,
"basis": "pins only — no curated country analysis for this country"
},
{
"country": "Sierra Leone",
"iso3": "SLE",
"region": "Africa",
"people_groups": 28,
"groups_with_nothing": 3,
"pct_with_nothing": 10.7,
"people_with_nothing": 98950,
"groups_with_scripture": 21,
"groups_with_film": 24,
"band": "partial",
"growing_unreached": 0,
"deaths_day": 1.1,
"basis": "pins, with curated country analysis"
},
{
"country": "Norway",
"iso3": "NOR",
@ -1232,21 +1217,6 @@
"deaths_day": null,
"basis": "pins only — no curated country analysis for this country"
},
{
"country": "Afghanistan",
"iso3": "AFG",
"region": "Asia",
"people_groups": 45,
"groups_with_nothing": 2,
"pct_with_nothing": 4.4,
"people_with_nothing": 594000,
"groups_with_scripture": 15,
"groups_with_film": 25,
"band": "partial",
"growing_unreached": 17,
"deaths_day": 17.6,
"basis": "pins, with curated country analysis"
},
{
"country": "Uganda",
"iso3": "UGA",
@ -1427,6 +1397,21 @@
"deaths_day": 0,
"basis": "pins, with curated country analysis"
},
{
"country": "Sierra Leone",
"iso3": "SLE",
"region": "Africa",
"people_groups": 28,
"groups_with_nothing": 2,
"pct_with_nothing": 7.1,
"people_with_nothing": 98000,
"groups_with_scripture": 21,
"groups_with_film": 24,
"band": "partial",
"growing_unreached": 0,
"deaths_day": 1.1,
"basis": "pins, with curated country analysis"
},
{
"country": "Nicaragua",
"iso3": "NIC",
@ -1772,6 +1757,21 @@
"deaths_day": null,
"basis": "pins only — no curated country analysis for this country"
},
{
"country": "Afghanistan",
"iso3": "AFG",
"region": "Asia",
"people_groups": 45,
"groups_with_nothing": 1,
"pct_with_nothing": 2.2,
"people_with_nothing": 422000,
"groups_with_scripture": 15,
"groups_with_film": 25,
"band": "partial",
"growing_unreached": 17,
"deaths_day": 17.6,
"basis": "pins, with curated country analysis"
},
{
"country": "Poland",
"iso3": "POL",

View file

@ -6,8 +6,8 @@
"generated": "2026-09-25",
"groups": 2107,
"by_access": {
"nothing found": 381,
"watch or hear, cannot read": 794,
"nothing found": 369,
"watch or hear, cannot read": 806,
"read and watch": 869,
"read": 57,
"stories only": 6
@ -1001,15 +1001,15 @@
"iso639_3": "alk",
"population": 25500,
"religion": "Ethnoreligion - Animism",
"access": "nothing found",
"resources": 0,
"access": "watch or hear, cannot read",
"resources": 1,
"scripture": "",
"scripture_url": "",
"film_url": "",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
"gospel_audio_url": "",
"gospel_audio_url": "https://fivefish.org/alk",
"training_url": "",
"pray_url": "https://pray.doxa.life/alak?source=doxalife",
"people_praying": 2,
@ -1208,15 +1208,15 @@
"iso639_3": "nos",
"population": 9300,
"religion": "Ethnoreligion - Chinese Folk Religion",
"access": "nothing found",
"resources": 0,
"access": "watch or hear, cannot read",
"resources": 1,
"scripture": "",
"scripture_url": "",
"film_url": "",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
"gospel_audio_url": "",
"gospel_audio_url": "https://fivefish.org/nos",
"training_url": "",
"pray_url": "https://pray.doxa.life/alu?source=doxalife",
"people_praying": 3,
@ -2588,15 +2588,15 @@
"iso639_3": "jle",
"population": 2400,
"religion": "Islam - Sunni",
"access": "nothing found",
"resources": 0,
"access": "watch or hear, cannot read",
"resources": 1,
"scripture": "",
"scripture_url": "",
"film_url": "",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
"gospel_audio_url": "",
"gospel_audio_url": "https://fivefish.org/jle",
"training_url": "",
"pray_url": "https://pray.doxa.life/arra?source=doxalife",
"people_praying": 0,
@ -8568,15 +8568,15 @@
"iso639_3": "nos",
"population": 10500,
"religion": "Ethnoreligion - Chinese Folk Religion",
"access": "nothing found",
"resources": 0,
"access": "watch or hear, cannot read",
"resources": 1,
"scripture": "",
"scripture_url": "",
"film_url": "",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
"gospel_audio_url": "",
"gospel_audio_url": "https://fivefish.org/nos",
"training_url": "",
"pray_url": "https://pray.doxa.life/chesu?source=doxalife",
"people_praying": 0,
@ -13651,15 +13651,15 @@
"iso639_3": "jle",
"population": 14500,
"religion": "Islam - Sunni",
"access": "nothing found",
"resources": 0,
"access": "watch or hear, cannot read",
"resources": 1,
"scripture": "",
"scripture_url": "",
"film_url": "",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
"gospel_audio_url": "",
"gospel_audio_url": "https://fivefish.org/jle",
"training_url": "",
"pray_url": "https://pray.doxa.life/doloka?source=doxalife",
"people_praying": 1,
@ -14387,15 +14387,15 @@
"iso639_3": "lma",
"population": 7500,
"religion": "Ethnoreligion - Animism",
"access": "nothing found",
"resources": 0,
"access": "watch or hear, cannot read",
"resources": 1,
"scripture": "",
"scripture_url": "",
"film_url": "",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
"gospel_audio_url": "",
"gospel_audio_url": "https://fivefish.org/lma",
"training_url": "",
"pray_url": "https://pray.doxa.life/east-limba?source=doxalife",
"people_praying": 1,
@ -23541,15 +23541,15 @@
"iso639_3": "ngt",
"population": 15000,
"religion": "Ethnoreligion - Animism",
"access": "nothing found",
"resources": 0,
"access": "watch or hear, cannot read",
"resources": 1,
"scripture": "",
"scripture_url": "",
"film_url": "",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
"gospel_audio_url": "",
"gospel_audio_url": "https://fivefish.org/ngt",
"training_url": "",
"pray_url": "https://pray.doxa.life/khlor?source=doxalife",
"people_praying": 0,
@ -26692,15 +26692,15 @@
"iso639_3": "nos",
"population": 10500,
"religion": "Ethnoreligion - Chinese Folk Religion",
"access": "nothing found",
"resources": 0,
"access": "watch or hear, cannot read",
"resources": 1,
"scripture": "",
"scripture_url": "",
"film_url": "",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
"gospel_audio_url": "",
"gospel_audio_url": "https://fivefish.org/nos",
"training_url": "",
"pray_url": "https://pray.doxa.life/laowu?source=doxalife",
"people_praying": 0,
@ -26876,15 +26876,15 @@
"iso639_3": "lbo",
"population": 56500,
"religion": "Ethnoreligion - Animism",
"access": "nothing found",
"resources": 0,
"access": "watch or hear, cannot read",
"resources": 1,
"scripture": "",
"scripture_url": "",
"film_url": "",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
"gospel_audio_url": "",
"gospel_audio_url": "https://fivefish.org/lbo",
"training_url": "",
"pray_url": "https://pray.doxa.life/laven?source=doxalife",
"people_praying": 0,
@ -33247,15 +33247,15 @@
"iso639_3": "jle",
"population": 117000,
"religion": "Islam - Sunni",
"access": "nothing found",
"resources": 0,
"access": "watch or hear, cannot read",
"resources": 1,
"scripture": "",
"scripture_url": "",
"film_url": "",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
"gospel_audio_url": "",
"gospel_audio_url": "https://fivefish.org/jle",
"training_url": "",
"pray_url": "https://pray.doxa.life/ngile?source=doxalife",
"people_praying": 0,
@ -33845,15 +33845,15 @@
"iso639_3": "nev",
"population": 11000,
"religion": "Ethnoreligion - Animism",
"access": "nothing found",
"resources": 0,
"access": "watch or hear, cannot read",
"resources": 1,
"scripture": "",
"scripture_url": "",
"film_url": "",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
"gospel_audio_url": "",
"gospel_audio_url": "https://fivefish.org/nev",
"training_url": "",
"pray_url": "https://pray.doxa.life/nyahon?source=doxalife",
"people_praying": 1,
@ -41527,15 +41527,15 @@
"iso639_3": "psi",
"population": 172000,
"religion": "Islam - Sunni",
"access": "nothing found",
"resources": 0,
"access": "watch or hear, cannot read",
"resources": 1,
"scripture": "",
"scripture_url": "",
"film_url": "",
"audio_url": "",
"bible_video_url": "",
"stories_url": "",
"gospel_audio_url": "",
"gospel_audio_url": "https://fivefish.org/psi",
"training_url": "",
"pray_url": "https://pray.doxa.life/southeast-pashayi?source=doxalife",
"people_praying": 1,

View file

@ -943,7 +943,7 @@
"lng": 106.59719,
"iso639_3": "alk",
"language": "Alak",
"tier": 0,
"tier": 1,
"imb_total_resources": 1,
"engagement": "Unengaged"
},
@ -1159,7 +1159,7 @@
"lng": 102.744099,
"iso639_3": "nos",
"language": "Nisu, Eastern",
"tier": 0,
"tier": 1,
"imb_total_resources": 1,
"engagement": "Unengaged"
},
@ -2599,7 +2599,7 @@
"lng": 30.00524,
"iso639_3": "jle",
"language": "Ngile",
"tier": 0,
"tier": 1,
"imb_total_resources": 1,
"engagement": "Unengaged"
},
@ -8839,7 +8839,7 @@
"lng": 101.821229,
"iso639_3": "nos",
"language": "Nisu, Eastern",
"tier": 0,
"tier": 1,
"imb_total_resources": 1,
"engagement": "Unengaged"
},
@ -14143,7 +14143,7 @@
"lng": 29.748111,
"iso639_3": "jle",
"language": "Ngile",
"tier": 0,
"tier": 1,
"imb_total_resources": 1,
"engagement": "Unengaged"
},
@ -14911,7 +14911,7 @@
"lng": -11.73884,
"iso639_3": "lma",
"language": "Limba, East",
"tier": 0,
"tier": 1,
"imb_total_resources": 1,
"engagement": "Unengaged"
},
@ -23903,7 +23903,7 @@
"religion": "Hinduism",
"population": 166000,
"people_praying": 0,
"people_committed": 1,
"people_committed": 2,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",
@ -24239,7 +24239,7 @@
"religion": "Hinduism",
"population": 11500,
"people_praying": 2,
"people_committed": 3,
"people_committed": 4,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",
@ -24463,7 +24463,7 @@
"lng": 106.5484,
"iso639_3": "ngt",
"language": "Kriang",
"tier": 0,
"tier": 1,
"imb_total_resources": 2,
"engagement": "Unengaged"
},
@ -27751,7 +27751,7 @@
"lng": 102.7273,
"iso639_3": "nos",
"language": "Nisu, Eastern",
"tier": 0,
"tier": 1,
"imb_total_resources": 1,
"engagement": "Unengaged"
},
@ -27943,7 +27943,7 @@
"lng": 106.32196,
"iso639_3": "lbo",
"language": "Laven",
"tier": 0,
"tier": 1,
"imb_total_resources": 1,
"engagement": "Unengaged"
},
@ -34591,7 +34591,7 @@
"lng": 29.835037,
"iso639_3": "jle",
"language": "Ngile",
"tier": 0,
"tier": 1,
"imb_total_resources": 1,
"engagement": "Unengaged"
},
@ -35215,7 +35215,7 @@
"lng": 106.54295,
"iso639_3": "nev",
"language": "Nyaheun",
"tier": 0,
"tier": 1,
"imb_total_resources": 1,
"engagement": "Unengaged"
},
@ -39575,7 +39575,7 @@
"religion": "Islam - Sunni",
"population": 86000,
"people_praying": 2,
"people_committed": 2,
"people_committed": 1,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",
@ -43231,7 +43231,7 @@
"lng": 70.449337,
"iso639_3": "psi",
"language": "Pashai, Southeast",
"tier": 0,
"tier": 1,
"imb_total_resources": 1,
"engagement": "Unengaged"
},

View file

@ -9983,8 +9983,8 @@
"population": 172000,
"heart_has_scripture": false,
"heart_has_film": false,
"heart_has_any": false,
"opportunity": true,
"heart_has_any": true,
"opportunity": false,
"bridge_language": "Dari",
"bridge_iso639_3": "prs",
"bridge_groups_in_country": 2,
@ -9997,7 +9997,7 @@
"top_channel": "WhatsApp Messenger",
"channel_source": "app-store ranking",
"aligned": false,
"status": "opportunity — bridge language has a resource",
"status": "reached in heart language",
"caveat": "Bridge = a language of the same country spoken by >1 DX group. NOT proof this group speaks it. Resource status = pools checked (eBible + JESUS Film); audio pools are key-blocked."
},
{
@ -11647,8 +11647,8 @@
"population": 117000,
"heart_has_scripture": false,
"heart_has_film": false,
"heart_has_any": false,
"opportunity": true,
"heart_has_any": true,
"opportunity": false,
"bridge_language": "Arabic, Sudanese",
"bridge_iso639_3": "apd",
"bridge_groups_in_country": 65,
@ -11661,7 +11661,7 @@
"top_channel": "Twitter",
"channel_source": "referral share",
"aligned": false,
"status": "opportunity — bridge language has a resource",
"status": "reached in heart language",
"caveat": "Bridge = a language of the same country spoken by >1 DX group. NOT proof this group speaks it. Resource status = pools checked (eBible + JESUS Film); audio pools are key-blocked."
},
{
@ -15677,8 +15677,8 @@
"population": 56500,
"heart_has_scripture": false,
"heart_has_film": false,
"heart_has_any": false,
"opportunity": true,
"heart_has_any": true,
"opportunity": false,
"bridge_language": "Akha",
"bridge_iso639_3": "ahk",
"bridge_groups_in_country": 12,
@ -15690,8 +15690,8 @@
"internet_pct": 65.6,
"top_channel": "WhatsApp Messenger",
"channel_source": "app-store ranking",
"aligned": true,
"status": "opportunity — bridge language has a resource",
"aligned": false,
"status": "reached in heart language",
"caveat": "Bridge = a language of the same country spoken by >1 DX group. NOT proof this group speaks it. Resource status = pools checked (eBible + JESUS Film); audio pools are key-blocked."
},
{
@ -22099,8 +22099,8 @@
"population": 25500,
"heart_has_scripture": false,
"heart_has_film": false,
"heart_has_any": false,
"opportunity": true,
"heart_has_any": true,
"opportunity": false,
"bridge_language": "Akha",
"bridge_iso639_3": "ahk",
"bridge_groups_in_country": 12,
@ -22112,8 +22112,8 @@
"internet_pct": 65.6,
"top_channel": "WhatsApp Messenger",
"channel_source": "app-store ranking",
"aligned": true,
"status": "opportunity — bridge language has a resource",
"aligned": false,
"status": "reached in heart language",
"caveat": "Bridge = a language of the same country spoken by >1 DX group. NOT proof this group speaks it. Resource status = pools checked (eBible + JESUS Film); audio pools are key-blocked."
},
{
@ -26545,8 +26545,8 @@
"population": 15000,
"heart_has_scripture": false,
"heart_has_film": false,
"heart_has_any": false,
"opportunity": true,
"heart_has_any": true,
"opportunity": false,
"bridge_language": "Akha",
"bridge_iso639_3": "ahk",
"bridge_groups_in_country": 12,
@ -26558,8 +26558,8 @@
"internet_pct": 65.6,
"top_channel": "WhatsApp Messenger",
"channel_source": "app-store ranking",
"aligned": true,
"status": "opportunity — bridge language has a resource",
"aligned": false,
"status": "reached in heart language",
"caveat": "Bridge = a language of the same country spoken by >1 DX group. NOT proof this group speaks it. Resource status = pools checked (eBible + JESUS Film); audio pools are key-blocked."
},
{
@ -26831,8 +26831,8 @@
"population": 14500,
"heart_has_scripture": false,
"heart_has_film": false,
"heart_has_any": false,
"opportunity": true,
"heart_has_any": true,
"opportunity": false,
"bridge_language": "Arabic, Sudanese",
"bridge_iso639_3": "apd",
"bridge_groups_in_country": 65,
@ -26845,7 +26845,7 @@
"top_channel": "Twitter",
"channel_source": "referral share",
"aligned": false,
"status": "opportunity — bridge language has a resource",
"status": "reached in heart language",
"caveat": "Bridge = a language of the same country spoken by >1 DX group. NOT proof this group speaks it. Resource status = pools checked (eBible + JESUS Film); audio pools are key-blocked."
},
{
@ -29067,8 +29067,8 @@
"population": 11000,
"heart_has_scripture": false,
"heart_has_film": false,
"heart_has_any": false,
"opportunity": true,
"heart_has_any": true,
"opportunity": false,
"bridge_language": "Akha",
"bridge_iso639_3": "ahk",
"bridge_groups_in_country": 12,
@ -29080,8 +29080,8 @@
"internet_pct": 65.6,
"top_channel": "WhatsApp Messenger",
"channel_source": "app-store ranking",
"aligned": true,
"status": "opportunity — bridge language has a resource",
"aligned": false,
"status": "reached in heart language",
"caveat": "Bridge = a language of the same country spoken by >1 DX group. NOT proof this group speaks it. Resource status = pools checked (eBible + JESUS Film); audio pools are key-blocked."
},
{
@ -29171,8 +29171,8 @@
"population": 10500,
"heart_has_scripture": false,
"heart_has_film": false,
"heart_has_any": false,
"opportunity": true,
"heart_has_any": true,
"opportunity": false,
"bridge_language": "Chinese, Mandarin",
"bridge_iso639_3": "cmn",
"bridge_groups_in_country": 25,
@ -29184,8 +29184,8 @@
"internet_pct": 92,
"top_channel": "Twitter",
"channel_source": "referral share",
"aligned": true,
"status": "opportunity — bridge language has a resource",
"aligned": false,
"status": "reached in heart language",
"caveat": "Bridge = a language of the same country spoken by >1 DX group. NOT proof this group speaks it. Resource status = pools checked (eBible + JESUS Film); audio pools are key-blocked."
},
{
@ -29327,8 +29327,8 @@
"population": 10500,
"heart_has_scripture": false,
"heart_has_film": false,
"heart_has_any": false,
"opportunity": true,
"heart_has_any": true,
"opportunity": false,
"bridge_language": "Chinese, Mandarin",
"bridge_iso639_3": "cmn",
"bridge_groups_in_country": 25,
@ -29340,8 +29340,8 @@
"internet_pct": 92,
"top_channel": "Twitter",
"channel_source": "referral share",
"aligned": true,
"status": "opportunity — bridge language has a resource",
"aligned": false,
"status": "reached in heart language",
"caveat": "Bridge = a language of the same country spoken by >1 DX group. NOT proof this group speaks it. Resource status = pools checked (eBible + JESUS Film); audio pools are key-blocked."
},
{
@ -30315,8 +30315,8 @@
"population": 9300,
"heart_has_scripture": false,
"heart_has_film": false,
"heart_has_any": false,
"opportunity": true,
"heart_has_any": true,
"opportunity": false,
"bridge_language": "Chinese, Mandarin",
"bridge_iso639_3": "cmn",
"bridge_groups_in_country": 25,
@ -30328,8 +30328,8 @@
"internet_pct": 92,
"top_channel": "Twitter",
"channel_source": "referral share",
"aligned": true,
"status": "opportunity — bridge language has a resource",
"aligned": false,
"status": "reached in heart language",
"caveat": "Bridge = a language of the same country spoken by >1 DX group. NOT proof this group speaks it. Resource status = pools checked (eBible + JESUS Film); audio pools are key-blocked."
},
{
@ -31953,8 +31953,8 @@
"population": 7500,
"heart_has_scripture": false,
"heart_has_film": false,
"heart_has_any": false,
"opportunity": true,
"heart_has_any": true,
"opportunity": false,
"bridge_language": "Pulaar",
"bridge_iso639_3": "fuc",
"bridge_groups_in_country": 2,
@ -31966,8 +31966,8 @@
"internet_pct": 33.3,
"top_channel": "Facebook",
"channel_source": "referral share",
"aligned": true,
"status": "opportunity — bridge language has a resource",
"aligned": false,
"status": "reached in heart language",
"caveat": "Bridge = a language of the same country spoken by >1 DX group. NOT proof this group speaks it. Resource status = pools checked (eBible + JESUS Film); audio pools are key-blocked."
},
{
@ -40689,8 +40689,8 @@
"population": 2400,
"heart_has_scripture": false,
"heart_has_film": false,
"heart_has_any": false,
"opportunity": true,
"heart_has_any": true,
"opportunity": false,
"bridge_language": "Arabic, Sudanese",
"bridge_iso639_3": "apd",
"bridge_groups_in_country": 65,
@ -40703,7 +40703,7 @@
"top_channel": "Twitter",
"channel_source": "referral share",
"aligned": false,
"status": "opportunity — bridge language has a resource",
"status": "reached in heart language",
"caveat": "Bridge = a language of the same country spoken by >1 DX group. NOT proof this group speaks it. Resource status = pools checked (eBible + JESUS Film); audio pools are key-blocked."
},
{

View file

@ -14,14 +14,14 @@
"country": "People's Republic of China",
"region": "Asia",
"groups": 391,
"no_resource": 83,
"pop_no_resource": 127348300,
"no_resource": 76,
"pop_no_resource": 125832700,
"scripture": 118,
"film": 227,
"growing_unreached": 0,
"deaths_day": 274.2,
"growth": -0.12,
"pct_no_resource": 21.2,
"pct_no_resource": 19.4,
"band": "partial",
"curated_groups": 158
},
@ -30,14 +30,14 @@
"country": "Lao People's Democratic Republic",
"region": "Asia",
"groups": 158,
"no_resource": 45,
"pop_no_resource": 692685,
"no_resource": 38,
"pop_no_resource": 553985,
"scripture": 55,
"film": 58,
"growing_unreached": 0,
"deaths_day": 15.5,
"growth": null,
"pct_no_resource": 28.5,
"pct_no_resource": 24.1,
"band": "partial",
"curated_groups": 90
},
@ -94,14 +94,14 @@
"country": "Sudan",
"region": "Africa",
"groups": 185,
"no_resource": 42,
"pop_no_resource": 2414100,
"no_resource": 39,
"pop_no_resource": 2280200,
"scripture": 96,
"film": 117,
"growing_unreached": 36,
"deaths_day": 359.6,
"growth": 0.81,
"pct_no_resource": 22.7,
"pct_no_resource": 21.1,
"band": "partial",
"curated_groups": 105
},
@ -142,14 +142,14 @@
"country": "Afghanistan",
"region": "Asia",
"groups": 45,
"no_resource": 2,
"pop_no_resource": 594000,
"no_resource": 1,
"pop_no_resource": 422000,
"scripture": 15,
"film": 25,
"growing_unreached": 17,
"deaths_day": 17.6,
"growth": 2.84,
"pct_no_resource": 4.4,
"pct_no_resource": 2.2,
"band": "partial",
"curated_groups": 21
},
@ -334,14 +334,14 @@
"country": "Guinea",
"region": "Africa",
"groups": 41,
"no_resource": 4,
"pop_no_resource": 146600,
"no_resource": 3,
"pop_no_resource": 139000,
"scripture": 19,
"film": 23,
"growing_unreached": 7,
"deaths_day": 14.5,
"growth": 2.4,
"pct_no_resource": 9.8,
"pct_no_resource": 7.3,
"band": "partial",
"curated_groups": 13
},
@ -2238,14 +2238,14 @@
"country": "Sierra Leone",
"region": "Africa",
"groups": 28,
"no_resource": 3,
"pop_no_resource": 98950,
"no_resource": 2,
"pop_no_resource": 98000,
"scripture": 21,
"film": 24,
"growing_unreached": 0,
"deaths_day": 1.1,
"growth": 2.12,
"pct_no_resource": 10.7,
"pct_no_resource": 7.1,
"band": "partial",
"curated_groups": 2
},
@ -2414,14 +2414,14 @@
"country": "United States of America",
"region": "Americas",
"groups": 623,
"no_resource": 18,
"pop_no_resource": 322725,
"no_resource": 17,
"pop_no_resource": 285225,
"scripture": 517,
"film": 549,
"growing_unreached": 0,
"deaths_day": 0.1,
"growth": 0.96,
"pct_no_resource": 2.9,
"pct_no_resource": 2.7,
"band": "partial",
"curated_groups": 3
},

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,8 +2,8 @@
"source": "https://fivefish.org/<iso639-3> (5fish, Global Recordings Network)",
"fetched": "2026-09-25",
"_note": "GRN gospel AUDIO, typed as Gospel audio and never as Scripture — \"Words of Life\" is gospel messages and Bible stories, not a translation of the canon. The host never 404s: a nonsense path returns a geo-located page with HTTP 200, so existence is read from the page title, with a negative and a positive control run in band every time. Their robots.txt asks for Crawl-delay: 10 and this fetcher obeys it, sequentially.",
"codes": 1022,
"decidedNone": 1325,
"codes": 1030,
"decidedNone": 1314,
"byIso": {
"mnp": {
"u": "https://fivefish.org/mnp",
@ -4092,6 +4092,38 @@
"ora": {
"u": "https://fivefish.org/ora",
"n": "Oroha"
},
"nos": {
"u": "https://fivefish.org/nos",
"n": "Nisu, Eastern"
},
"jle": {
"u": "https://fivefish.org/jle",
"n": "Ngile"
},
"alk": {
"u": "https://fivefish.org/alk",
"n": "Alak"
},
"lma": {
"u": "https://fivefish.org/lma",
"n": "Limba, East"
},
"psi": {
"u": "https://fivefish.org/psi",
"n": "Pashai, Southeast"
},
"lbo": {
"u": "https://fivefish.org/lbo",
"n": "Laven"
},
"ngt": {
"u": "https://fivefish.org/ngt",
"n": "Kriang"
},
"nev": {
"u": "https://fivefish.org/nev",
"n": "Nyaheun"
}
},
"none": [
@ -4104,7 +4136,6 @@
"nsi",
"zlj",
"acx",
"nos",
"qcd",
"zgb",
"eth",
@ -4135,7 +4166,6 @@
"asp",
"vic",
"aed",
"qns",
"han",
"qiq",
"mry",
@ -4200,7 +4230,6 @@
"dux",
"ajz",
"mdi",
"psi",
"bxu",
"nrb",
"cda",
@ -4223,7 +4252,6 @@
"dpp",
"muq",
"tbi",
"jle",
"buc",
"qfa",
"tse",
@ -4253,7 +4281,6 @@
"sqm",
"bdr",
"bct",
"lbo",
"mei",
"bfl",
"sgg",
@ -4404,13 +4431,11 @@
"tey",
"kcu",
"biw",
"ngt",
"ymc",
"baw",
"tlu",
"gey",
"ttd",
"alk",
"kko",
"xdq",
"gdi",
@ -4622,7 +4647,6 @@
"put",
"wha",
"ije",
"nev",
"acy",
"jpr",
"phh",
@ -4674,7 +4698,6 @@
"mcm",
"bml",
"aoa",
"lma",
"ykn",
"tgu",
"qgy",
@ -5125,7 +5148,6 @@
"gmu",
"rea",
"nee",
"qki",
"wnb",
"bcd",
"yee",
@ -5295,7 +5317,6 @@
"wab",
"trb",
"jur",
"xor",
"dbe",
"tad",
"lce",

View file

@ -389,7 +389,7 @@
"has_scripture": false,
"full_bible": false,
"has_film": false,
"carries_resource": false,
"carries_resource": true,
"country_people_groups": 154
},
{
@ -479,7 +479,7 @@
"has_scripture": false,
"full_bible": false,
"has_film": false,
"carries_resource": false,
"carries_resource": true,
"country_people_groups": 105
},
{
@ -2504,7 +2504,7 @@
"has_scripture": false,
"full_bible": false,
"has_film": false,
"carries_resource": false,
"carries_resource": true,
"country_people_groups": 21
},
{
@ -3959,7 +3959,7 @@
"has_scripture": false,
"full_bible": false,
"has_film": false,
"carries_resource": false,
"carries_resource": true,
"country_people_groups": 13
},
{

File diff suppressed because one or more lines are too long

View file

@ -1643,8 +1643,8 @@
"has_audio": "unknown",
"has_open_bible_stories": false,
"obs_note": "",
"no_resource_found": true,
"any_resource_found": false,
"no_resource_found": false,
"any_resource_found": true,
"scripture_url": "",
"film_url": "",
"code_unmatchable": false,
@ -1979,8 +1979,8 @@
"has_audio": "unknown",
"has_open_bible_stories": false,
"obs_note": "",
"no_resource_found": true,
"any_resource_found": false,
"no_resource_found": false,
"any_resource_found": true,
"scripture_url": "",
"film_url": "",
"code_unmatchable": false,
@ -2003,8 +2003,8 @@
"has_audio": "unknown",
"has_open_bible_stories": false,
"obs_note": "",
"no_resource_found": true,
"any_resource_found": false,
"no_resource_found": false,
"any_resource_found": true,
"scripture_url": "",
"film_url": "",
"code_unmatchable": false,
@ -2027,8 +2027,8 @@
"has_audio": "unknown",
"has_open_bible_stories": false,
"obs_note": "",
"no_resource_found": true,
"any_resource_found": false,
"no_resource_found": false,
"any_resource_found": true,
"scripture_url": "",
"film_url": "",
"code_unmatchable": false,
@ -3443,8 +3443,8 @@
"has_audio": "unknown",
"has_open_bible_stories": false,
"obs_note": "",
"no_resource_found": true,
"any_resource_found": false,
"no_resource_found": false,
"any_resource_found": true,
"scripture_url": "",
"film_url": "",
"code_unmatchable": false,
@ -5435,8 +5435,8 @@
"has_audio": "unknown",
"has_open_bible_stories": false,
"obs_note": "",
"no_resource_found": true,
"any_resource_found": false,
"no_resource_found": false,
"any_resource_found": true,
"scripture_url": "",
"film_url": "",
"code_unmatchable": false,
@ -5459,8 +5459,8 @@
"has_audio": "unknown",
"has_open_bible_stories": false,
"obs_note": "",
"no_resource_found": true,
"any_resource_found": false,
"no_resource_found": false,
"any_resource_found": true,
"scripture_url": "",
"film_url": "",
"code_unmatchable": false,
@ -5483,8 +5483,8 @@
"has_audio": "unknown",
"has_open_bible_stories": false,
"obs_note": "",
"no_resource_found": true,
"any_resource_found": false,
"no_resource_found": false,
"any_resource_found": true,
"scripture_url": "",
"film_url": "",
"code_unmatchable": false,
@ -6203,8 +6203,8 @@
"has_audio": "unknown",
"has_open_bible_stories": false,
"obs_note": "",
"no_resource_found": true,
"any_resource_found": false,
"no_resource_found": false,
"any_resource_found": true,
"scripture_url": "",
"film_url": "",
"code_unmatchable": false,
@ -7835,8 +7835,8 @@
"has_audio": "unknown",
"has_open_bible_stories": false,
"obs_note": "",
"no_resource_found": true,
"any_resource_found": false,
"no_resource_found": false,
"any_resource_found": true,
"scripture_url": "",
"film_url": "",
"code_unmatchable": false,
@ -8987,8 +8987,8 @@
"has_audio": "unknown",
"has_open_bible_stories": false,
"obs_note": "",
"no_resource_found": true,
"any_resource_found": false,
"no_resource_found": false,
"any_resource_found": true,
"scripture_url": "",
"film_url": "",
"code_unmatchable": false,
@ -9923,8 +9923,8 @@
"has_audio": "unknown",
"has_open_bible_stories": false,
"obs_note": "",
"no_resource_found": true,
"any_resource_found": false,
"no_resource_found": false,
"any_resource_found": true,
"scripture_url": "",
"film_url": "",
"code_unmatchable": false,

View file

@ -4,8 +4,8 @@
"source": "data/tables/translation-priority.json, itself built from the pin index — so this table, that one and the map cannot disagree.",
"generated": "2026-09-25",
"countries": 159,
"groups": 3115,
"people": 1330905094,
"groups": 3136,
"people": 1332911344,
"definition": "People groups with recorded audio, the JESUS Film, a Bible video or Words of Life in their language and NO text Scripture found in the pools checked, aggregated to the country a team would be sent to. \"Not found\" is a statement about those pools, never a claim that no text exists; the per-group table carries the checked and unchecked pools on every row.",
"two_columns_note": "groups and languages answer different questions from people. Nigeria and the DRC lead on languages, which is the translation workload; China and Pakistan lead on population across far fewer languages. Neither is ranked for you."
},
@ -55,9 +55,9 @@
{
"country": "People's Republic of China",
"iso3": "CHN",
"groups": 189,
"languages": 89,
"people": 218383850,
"groups": 196,
"languages": 90,
"people": 219899450,
"largest_group": "Han Chinese, Wu",
"largest_group_population": 92530000,
"basis": "no text Scripture found in the pools checked"
@ -155,9 +155,9 @@
{
"country": "Afghanistan",
"iso3": "AFG",
"groups": 28,
"languages": 28,
"people": 26167200,
"groups": 29,
"languages": 29,
"people": 26339200,
"largest_group": "Pashtun, Southern",
"largest_group_population": 13680000,
"basis": "no text Scripture found in the pools checked"
@ -262,6 +262,16 @@
"largest_group_population": 3035000,
"basis": "no text Scripture found in the pools checked"
},
{
"country": "Sudan",
"iso3": "SDN",
"groups": 48,
"languages": 39,
"people": 8892975,
"largest_group": "Beja",
"largest_group_population": 3220000,
"basis": "no text Scripture found in the pools checked"
},
{
"country": "Senegal",
"iso3": "SEN",
@ -272,16 +282,6 @@
"largest_group_population": 2545000,
"basis": "no text Scripture found in the pools checked"
},
{
"country": "Sudan",
"iso3": "SDN",
"groups": 45,
"languages": 38,
"people": 8759075,
"largest_group": "Beja",
"largest_group_population": 3220000,
"basis": "no text Scripture found in the pools checked"
},
{
"country": "Cote d'Ivoire",
"iso3": "CIV",
@ -335,9 +335,9 @@
{
"country": "Guinea",
"iso3": "GIN",
"groups": 18,
"languages": 15,
"people": 5947500,
"groups": 19,
"languages": 16,
"people": 5955100,
"largest_group": "Maninka",
"largest_group_population": 3855000,
"basis": "no text Scripture found in the pools checked"
@ -425,9 +425,9 @@
{
"country": "United States of America",
"iso3": "USA",
"groups": 79,
"languages": 76,
"people": 3530175,
"groups": 80,
"languages": 77,
"people": 3567675,
"largest_group": "Armenian",
"largest_group_population": 1180000,
"basis": "no text Scripture found in the pools checked"
@ -582,6 +582,16 @@
"largest_group_population": 1220000,
"basis": "no text Scripture found in the pools checked"
},
{
"country": "Lao People's Democratic Republic",
"iso3": "LAO",
"groups": 65,
"languages": 38,
"people": 2186025,
"largest_group": "White Thai",
"largest_group_population": 325000,
"basis": "no text Scripture found in the pools checked"
},
{
"country": "JOR",
"iso3": "JOR",
@ -612,16 +622,6 @@
"largest_group_population": 225000,
"basis": "no text Scripture found in the pools checked"
},
{
"country": "Lao People's Democratic Republic",
"iso3": "LAO",
"groups": 58,
"languages": 33,
"people": 2047325,
"largest_group": "White Thai",
"largest_group_population": 325000,
"basis": "no text Scripture found in the pools checked"
},
{
"country": "KOS",
"iso3": "KOS",
@ -915,9 +915,9 @@
{
"country": "Sierra Leone",
"iso3": "SLE",
"groups": 4,
"languages": 4,
"people": 532000,
"groups": 5,
"languages": 5,
"people": 532950,
"largest_group": "Sherbro",
"largest_group_population": 244000,
"basis": "no text Scripture found in the pools checked"

View file

@ -3,9 +3,9 @@
"title": "Translation priority — can hear it, no text found",
"source": "data/tables/people-group-pins.json — the same index the map draws, so this table and the map cannot disagree.",
"generated": "2026-09-25",
"groups": 3115,
"languages": 1900,
"people": 1330905094,
"groups": 3136,
"languages": 1908,
"people": 1332911344,
"definition": "A people group with recorded audio, the JESUS Film, a Bible video or Words of Life in their language, and NO text Scripture found in the pools checked. \"Not found\" is a statement about those pools, never a claim that no text exists — several of the largest rows are varieties where a recording plainly exists and a text in that same variety is contested. The checked and unchecked pools are on every row for that reason."
},
"columns": [
@ -2128,6 +2128,20 @@
"lat": 24.69,
"lng": 46.66
},
{
"people_group": "Nisu Yi",
"country": "People's Republic of China",
"iso3": "CHN",
"language": "Nisu, Eastern",
"iso639_3": "nos",
"population": 1430000,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/nos",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 23.99,
"lng": 102.12
},
{
"people_group": "Bulu",
"country": "Cameroon",
@ -9786,6 +9800,20 @@
"lat": -5.76,
"lng": 123.91
},
{
"people_group": "Pashayi, Southeast",
"country": "Afghanistan",
"iso3": "AFG",
"language": "psi",
"iso639_3": "psi",
"population": 172000,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/psi",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 34.42,
"lng": 70.45
},
{
"people_group": "Gha-Mu",
"country": "People's Republic of China",
@ -11970,6 +11998,20 @@
"lat": 12.13,
"lng": -2.98
},
{
"people_group": "Ngile",
"country": "Sudan",
"iso3": "SDN",
"language": "Ngile",
"iso639_3": "jle",
"population": 117000,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/jle",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 10.91,
"lng": 29.84
},
{
"people_group": "Burji",
"country": "Ethiopia",
@ -16394,6 +16436,20 @@
"lat": 9.35,
"lng": 11.05
},
{
"people_group": "Laven",
"country": "Lao People's Democratic Republic",
"iso3": "LAO",
"language": "Laven",
"iso639_3": "lbo",
"population": 56500,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/lbo",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 15.27,
"lng": 106.32
},
{
"people_group": "Numana",
"country": "Nigeria",
@ -19404,6 +19460,20 @@
"lat": 29.2,
"lng": 83.95
},
{
"people_group": "Boloven",
"country": "United States of America",
"iso3": "USA",
"language": "Laven",
"iso639_3": "lbo",
"population": 37500,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/lbo",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 36.72,
"lng": -119.73
},
{
"people_group": "Polci",
"country": "Nigeria",
@ -21336,6 +21406,20 @@
"lat": -6.18,
"lng": 14.36
},
{
"people_group": "Boka",
"country": "People's Republic of China",
"iso3": "CHN",
"language": "Nisu, Eastern",
"iso639_3": "nos",
"population": 28500,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/nos",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 23.17,
"lng": 103.87
},
{
"people_group": "Fuyuge",
"country": "Papua New Guinea",
@ -22078,6 +22162,20 @@
"lat": 12.84,
"lng": 75.25
},
{
"people_group": "Alak",
"country": "Lao People's Democratic Republic",
"iso3": "LAO",
"language": "Alak",
"iso639_3": "alk",
"population": 25500,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/alk",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 15.56,
"lng": 106.6
},
{
"people_group": "Jakun",
"country": "Malaysia",
@ -22386,6 +22484,20 @@
"lat": 9.48,
"lng": -5.64
},
{
"people_group": "Lesu",
"country": "People's Republic of China",
"iso3": "CHN",
"language": "Nisu, Eastern",
"iso639_3": "nos",
"population": 24500,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/nos",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 23.89,
"lng": 102.14
},
{
"people_group": "Pangwala",
"country": "India",
@ -24626,6 +24738,20 @@
"lat": 27.98,
"lng": 96.62
},
{
"people_group": "Ngae",
"country": "Lao People's Democratic Republic",
"iso3": "LAO",
"language": "Kriang",
"iso639_3": "ngt",
"population": 17500,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/ngt",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 15.52,
"lng": 106.75
},
{
"people_group": "Omo",
"country": "Papua New Guinea",
@ -25732,6 +25858,20 @@
"lat": -6.67,
"lng": 146.66
},
{
"people_group": "Khlor",
"country": "Lao People's Democratic Republic",
"iso3": "LAO",
"language": "Kriang",
"iso639_3": "ngt",
"population": 15000,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/ngt",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 15.68,
"lng": 106.55
},
{
"people_group": "Tai Nua",
"country": "Lao People's Democratic Republic",
@ -26068,6 +26208,20 @@
"lat": 17.69,
"lng": 120.66
},
{
"people_group": "Doloka",
"country": "Sudan",
"iso3": "SDN",
"language": "Ngile",
"iso639_3": "jle",
"population": 14500,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/jle",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 10.86,
"lng": 29.75
},
{
"people_group": "Shui, Yunnan",
"country": "People's Republic of China",
@ -27762,6 +27916,20 @@
"lat": 9.92,
"lng": 12.53
},
{
"people_group": "Laowu",
"country": "People's Republic of China",
"iso3": "CHN",
"language": "Nisu, Eastern",
"iso639_3": "nos",
"population": 11000,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/nos",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 22.74,
"lng": 102.73
},
{
"people_group": "Southern Efate",
"country": "Vanuatu",
@ -27972,6 +28140,20 @@
"lat": 10.65,
"lng": -5.41
},
{
"people_group": "Nyahon",
"country": "Lao People's Democratic Republic",
"iso3": "LAO",
"language": "Nyaheun",
"iso639_3": "nev",
"population": 11000,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/nev",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 15.1,
"lng": 106.54
},
{
"people_group": "Sayyid (Kashmiri)",
"country": "India",
@ -28476,6 +28658,20 @@
"lat": -22.27,
"lng": 166.41
},
{
"people_group": "Chesu",
"country": "People's Republic of China",
"iso3": "CHN",
"language": "Nisu, Eastern",
"iso639_3": "nos",
"population": 10500,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/nos",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 24.26,
"lng": 101.82
},
{
"people_group": "Igo",
"country": "Togo",
@ -29064,6 +29260,20 @@
"lat": -6.05,
"lng": 149.34
},
{
"people_group": "Alu",
"country": "People's Republic of China",
"iso3": "CHN",
"language": "Nisu, Eastern",
"iso639_3": "nos",
"population": 9400,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/nos",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 22.88,
"lng": 102.74
},
{
"people_group": "Ndasa",
"country": "Republic of the Congo",
@ -29918,6 +30128,20 @@
"lat": 7.11,
"lng": -67.6
},
{
"people_group": "Alu",
"country": "Lao People's Democratic Republic",
"iso3": "LAO",
"language": "Nisu, Eastern",
"iso639_3": "nos",
"population": 8100,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/nos",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 22.26,
"lng": 101.68
},
{
"people_group": "Sivandi",
"country": "Islamic Republic of Iran",
@ -30226,6 +30450,20 @@
"lat": -1.75,
"lng": 135.87
},
{
"people_group": "East Limba",
"country": "Guinea",
"iso3": "GIN",
"language": "Limba, East",
"iso639_3": "lma",
"population": 7600,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/lma",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 10.04,
"lng": -11.74
},
{
"people_group": "Kalash",
"country": "Pakistan",
@ -32690,6 +32928,20 @@
"lat": 48.85,
"lng": 2.31
},
{
"people_group": "Alak Sanak",
"country": "Lao People's Democratic Republic",
"iso3": "LAO",
"language": "Alak",
"iso639_3": "alk",
"population": 5100,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/alk",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 15.47,
"lng": 106.94
},
{
"people_group": "Langbashe",
"country": "Democratic Republic of the Congo",
@ -36400,6 +36652,20 @@
"lat": 17.89,
"lng": 105.49
},
{
"people_group": "Arra",
"country": "Sudan",
"iso3": "SDN",
"language": "Ngile",
"iso639_3": "jle",
"population": 2400,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/jle",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 10.64,
"lng": 30.01
},
{
"people_group": "Jerung",
"country": "Nepal",
@ -38234,6 +38500,20 @@
"lat": 51.43,
"lng": -0.21
},
{
"people_group": "Azong",
"country": "People's Republic of China",
"iso3": "CHN",
"language": "Nisu, Eastern",
"iso639_3": "nos",
"population": 1700,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/nos",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 22.74,
"lng": 101.98
},
{
"people_group": "Onabasulu",
"country": "Papua New Guinea",
@ -40152,6 +40432,20 @@
"lat": -13.25,
"lng": 166.61
},
{
"people_group": "East Limba",
"country": "Sierra Leone",
"iso3": "SLE",
"language": "Limba, East",
"iso639_3": "lma",
"population": 950,
"can_hear": "gospel audio (Words of Life)",
"first_link": "https://fivefish.org/lma",
"checked_pools": "arclight, door43, ebible, fcbh, grn, scriptureearth",
"unchecked_pools": "api_bible, dbl, progress_bible, youversion",
"lat": 9.38,
"lng": -11.76
},
{
"people_group": "Doupo",
"country": "People's Republic of China",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,018 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

@ -1,19 +1,19 @@
{
"cap1-single-element": {
"bundle": "all-people-groups",
"sha256": "d48c17dc369294301d17a4d29fc517cc7c2c2b18561b6d416ae02a9fbfb60c33"
"sha256": "db9e293144928477bc7fc1ef50f469db073d81575b29ba394b50d33273c6e1a5"
},
"cap2-adoption": {
"bundle": "doxa-simple-map",
"sha256": "08a7c14bc19b0ab915aca468af11e6466ae08c367ea4545252c72aff64d32357"
"sha256": "27f32e7a2168391286d190ecfdb9445fb1a610700f1250d0769c50cbd422d778"
},
"cap2-engagement": {
"bundle": "doxa-simple-map",
"sha256": "08a7c14bc19b0ab915aca468af11e6466ae08c367ea4545252c72aff64d32357"
"sha256": "27f32e7a2168391286d190ecfdb9445fb1a610700f1250d0769c50cbd422d778"
},
"cap2-prayer": {
"bundle": "doxa-simple-map",
"sha256": "08a7c14bc19b0ab915aca468af11e6466ae08c367ea4545252c72aff64d32357"
"sha256": "27f32e7a2168391286d190ecfdb9445fb1a610700f1250d0769c50cbd422d778"
},
"cap3-monolithic-research": {
"bundle": "doxa-research-map",
@ -33,11 +33,11 @@
},
"cap5-doxa-maps-spa": {
"bundle": "doxa-maps-spa",
"sha256": "428e3741bd0bdbbbbd8ddbfca51dd0bacd471d5387c33f5869f2d121bf5d40c1"
"sha256": "e24edf74f5609549b8d83250ebef269f3d6a00423cd3ca28b9a86e23bfba8e91"
},
"cap5-plugin-studio": {
"bundle": "plugin-studio",
"sha256": "951dcb60f59ce134fca591376cd65f54750bcb1ed50366f569be4cc863af92d2"
"sha256": "fee321fdfd05fc83567987c682f826f890cbf76722d9a94290335f2228b9dd61"
},
"deploy2-mobile-upg100": {
"bundle": "my-upg-100-list",

View file

@ -13,7 +13,7 @@
> bundle `parameterized` (cap-2) no matter how light it looks, and a `meta.json` `type`
> declares tiers 3/4/5.
Generated from 22 bundle(s).
Generated from 21 bundle(s).
### One canonical exemplar per tier (generated — quote these)
@ -52,8 +52,6 @@ Generated from 22 bundle(s).
| `prayer-walk-planner` | `prayer-walk-planner` | `single` |
| `regions-dashboard` | `region-dashboards` | `single` |
| `regions-dashboard` | `regions-list` | `single` |
| `template-bundle` | `template-map-a` | `single` |
| `template-bundle` | `template-map-b` | `single` |
## Capability 2 — preset-element (prop-table)

View file

@ -0,0 +1,106 @@
/**
* check-gospel-composed-filters.mjs — cell C: the map COMPOSED above its own tables.
*
* THE CLAIM UNDER TEST is not "it renders". It is that the composite keeps the reader INSIDE
* it. Standalone, the sheet's "Every resource in <language>" is a LINK to the tables page.
* Composed, it must become an in-place FILTER, because navigating away throws out the map the
* reader has just positioned and the group they have just found. Two different affordances
* behind the same words, and only one of them is right in each context.
*
* WHAT IT ASSERTS, and each one is a way the composite can look fine and be broken:
* - the control is a <button>, not an <a> (a link here navigates)
* - the URL does not change (proves it did not navigate)
* - the table's ROW COUNT goes down (proves the filter reached the data)
* - the row count stays above zero (proves it filtered rather than emptied)
* - the pin count is unchanged (proves the map survived the click)
* - no response >= 400 and no page error (proves it did not paper over a failure)
*
* IT PICKS ITS SUBJECT FROM THE DATA, and that is not a detail. The first run of this check
* opened pin 0 — Abai Sungai, a group with NOTHING found — so the filter correctly produced an
* empty table and the check failed a working composite. The test was wrong, not the product.
* It now selects a tier-2 pin from the live source, so "rows went down but not to zero" is a
* question the subject can actually answer.
*
* BROWSER GATE. Run it under both rendezvous locks, product lock first — the fixed order is
* what makes the pair deadlock-free:
* flock -w 1200 <product-lock> flock -w 1200 <agent-lock> bun run check:composed-filters
*/
import { chromium } from 'playwright'
import { serveReaped } from './_serve-reaped.mjs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const PUBLIC = resolve(HERE, '../public')
const PORT = Number(process.env.PORT || 5399)
const BASE = process.env.BASE_URL || `http://127.0.0.1:${PORT}`
const PATHNAME = '/doxa-maps/gospel-resources/gospel-dashboard/index.html'
let srv = null
if (!process.env.BASE_URL) srv = serveReaped(PUBLIC, PORT)
const fail = (msg) => { console.error(`COMPOSED-FILTERS: FAIL — ${msg}`); srv?.stop(); process.exit(1) }
let browser
try {
browser = await chromium.launch()
const p = await browser.newPage({ viewport: { width: 1440, height: 1200 } })
const bad = []
p.on('response', (r) => { if (r.status() >= 400) bad.push(`${r.status()} ${r.url()}`) })
p.on('pageerror', (e) => bad.push(`pageerror: ${String(e.message).slice(0, 120)}`))
await p.goto(`${BASE}${PATHNAME}`, { waitUntil: 'networkidle', timeout: 90000 })
await p.waitForFunction(() => window.__grState && window.__grState().pins > 0, { timeout: 60000 })
await p.waitForTimeout(8000)
const read = () => p.evaluate(() => {
const r = document.querySelector('gospel-resources-map').shadowRoot
return {
pins: window.__grState().rendered,
rows: r.querySelectorAll('.tabulator-row').length,
url: location.href,
chip: (r.querySelector('.gt-filter')?.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 100),
}
})
const before = await read()
if (!before.rows) fail('the composed tables rendered no rows at all, so a filter proves nothing.')
const idx = await p.evaluate(() => {
const f = window.__grMap.getSource('gr-pin-src')._data.features
const hit = f.find((x) => x.properties.tier === 2) || f[0]
return hit.properties.i
})
await p.evaluate((i) => window.__grOpenPin(i), idx)
await p.waitForTimeout(2000)
const clicked = await p.evaluate(() => {
const r = document.querySelector('gospel-resources-map').shadowRoot
const btn = [...r.querySelectorAll('.gm-tablink')].find((e) => /Every resource in/i.test(e.textContent))
if (!btn) return null
const tag = btn.tagName.toLowerCase()
btn.click()
return { tag, text: btn.textContent.trim().slice(0, 60) }
})
if (!clicked) fail('no "Every resource in ..." control in the group sheet.')
if (clicked.tag !== 'button') fail(`the control is a <${clicked.tag}>, not a <button> — composed, it must filter, not navigate.`)
await p.waitForTimeout(3000)
const after = await read()
console.log(` control : pins=${before.pins} rows=${before.rows}`)
console.log(` clicked : <${clicked.tag}> "${clicked.text}"`)
console.log(` after : pins=${after.pins} rows=${after.rows}`)
console.log(` filter : ${after.chip || '(none)'}`)
if (after.url !== before.url) fail(`it NAVIGATED (${before.url} -> ${after.url}); the map the reader positioned is gone.`)
if (after.pins !== before.pins) fail(`the map lost pins on the click: ${before.pins} -> ${after.pins}.`)
if (!(after.rows < before.rows)) fail(`the table did not narrow: ${before.rows} -> ${after.rows}.`)
if (after.rows === 0) fail('the table emptied rather than filtered — subject had no resources, so this proves nothing.')
if (bad.length) fail(`${bad.length} failed request(s) or page error(s): ${[...new Set(bad)].slice(0, 3).join(' | ')}`)
console.log('COMPOSED-FILTERS: PASS — filters in place, keeps the map and the URL, no failed requests.')
} finally {
await browser?.close()
srv?.stop()
}

View file

@ -25,7 +25,7 @@
*
* NEGATIVE-TESTED: restoring either label ('% unreached' or 'Adopted %') fails by name with exit 1.
*/
import { readFileSync, existsSync } from 'node:fs'
import { readFileSync, existsSync, readdirSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -41,10 +41,22 @@ const src = readFileSync(CFG, 'utf8')
// Strip comments before scanning. This file now DOCUMENTS the defect in a comment right above the
// fixed line, and a naive scan matches that prose and fails on the explanation of the fix — the
// same self-reference that has bitten three gates in this repo already.
// The translated labels live in the catalogue, so the rule has to reach them too.
const LOC_DIR = resolve(ROOT, 'library/i18n/locales')
const LOCALES = existsSync(LOC_DIR) ? readdirSync(LOC_DIR) : []
const catalogue = (loc) => {
try { return JSON.parse(readFileSync(resolve(LOC_DIR, loc, 'dashboard.json'), 'utf8')) } catch { return {} }
}
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
// Each KPI entry is a single-line-ish object literal: { id: '…', label: '…', kind: '…', … }
const entries = [...code.matchAll(/\{[^{}]*\bid:\s*'([^']+)'[^{}]*\}/g)]
// Each KPI entry is an object literal: { id: '…', label: '…', kind: '…', … }.
// ★ IT MAY NOW CONTAIN ONE LEVEL OF NESTED BRACES. The labels became GETTERS when the dashboard was
// translated (`get label() { return tStatic('dashboard.unreached') || 'unreached' }`), and the old
// single-brace pattern stopped matching every entry — the gate found ZERO pct KPIs and failed
// exactly as it was built to, rather than printing a green PASS about nothing. That refusal is the
// gate working; this is the parse catching up with the config, not a relaxation of the rule.
const entries = [...code.matchAll(/\{(?:[^{}]|\{[^{}]*\})*\bid:\s*'([^']+)'(?:[^{}]|\{[^{}]*\})*\}/g)]
const offenders = []
let pctSeen = 0
@ -53,8 +65,20 @@ for (const m of entries) {
const kind = (body.match(/\bkind:\s*'([^']+)'/) || [])[1]
if (kind !== 'pct') continue
pctSeen++
const label = (body.match(/\blabel:\s*'([^']*)'/) || [])[1]
// Two label forms: a plain literal, or a getter that falls back to one.
const plain = (body.match(/\blabel:\s*'([^']*)'/) || [])[1]
const viaGetter = body.match(/get label\(\)\s*\{[^}]*?tStatic\('([^']+)'\)\s*\|\|\s*'([^']*)'/)
const label = plain ?? (viaGetter ? viaGetter[2] : undefined)
if (label && label.includes('%')) offenders.push({ id: m[1], label })
// ★ AND CHECK WHAT A NON-ENGLISH READER SEES. The rule is about the rendered label carrying a
// second percent sign, and a translation can reintroduce one where the English fallback has none.
if (viaGetter) {
const key = viaGetter[1].replace(/^dashboard\./, '')
for (const loc of LOCALES) {
const v = catalogue(loc)[key]
if (typeof v === 'string' && v.includes('%')) offenders.push({ id: m[1] + ' [' + loc + ']', label: v })
}
}
}
if (!pctSeen) {

View file

@ -31,9 +31,40 @@ const SUPPORTED = (src.match(/SUPPORTED_LOCALES = \[([^\]]*)\]/) || [])[1]
// Evaluate the SHIPPED detectLocale rather than reimplementing it — a gate that
// reimplements the thing it tests only ever proves the copy agrees with itself.
const body = src.slice(src.indexOf('export function detectLocale'), src.indexOf('export function createAppI18n'))
// ★ TAKE THE FUNCTION, NOT THE GAP BETWEEN TWO LANDMARKS.
// This used to slice from `export function detectLocale` to `export function createAppI18n` and eval
// whatever lay between. That is a promise about the ORDER OF A FILE the gate does not own: the day
// someone exported a helper between those two lines, the slice carried a bare `export` into
// new Function() and this gate died with "Unexpected token 'export'" — twice, once for a helper I
// added myself. It was then "fixed" by moving the helper, which only re-armed the trap for the next
// person. Extract detectLocale by matching its own braces, and strip any export keyword that
// survives, so the gate reads the function it names and nothing else.
function extractFunction(source, name) {
const start = source.search(new RegExp('(export\\s+)?function\\s+' + name + '\\s*\\('))
if (start < 0) return ''
const open = source.indexOf('{', start)
if (open < 0) return ''
let depth = 0
for (let i = open; i < source.length; i++) {
const ch = source[i]
if (ch === '{') depth++
else if (ch === '}') {
depth--
if (depth === 0) return source.slice(start, i + 1)
}
}
return ''
}
const body = extractFunction(src, 'detectLocale').replace(/\bexport\s+/g, '')
if (!body) {
console.error('LOCALE-REACHABLE: FAIL — could not find detectLocale() in library/i18n/index.js.')
console.error(' The gate evaluates the SHIPPED function rather than reimplementing it, so if the')
console.error(' function is renamed this must be updated — silence here would be a gate that')
console.error(' stopped testing anything.')
process.exit(1)
}
const make = new Function('SUPPORTED_LOCALES', 'window', 'document',
`${body.replace('export function', 'return function')}\n; return detectLocale;`)
`${body}\n; return detectLocale;`)
const detect = (search, htmlLang) =>
make(SUPPORTED, { location: { search } }, { documentElement: { lang: htmlLang } })()

View file

@ -0,0 +1,152 @@
#!/usr/bin/env node
/**
* check-map-embed-cells — measure the PRODUCTION-MATRIX embed cells B (iframe) and C (script embed)
* for each production map, on a genuinely FOREIGN origin: the built tree on 127.0.0.1:<port>, a
* stand-in partner page on localhost:<other port>. Starts both servers itself; needs a built tree.
*
* USAGE: node internal/check-map-embed-cells.mjs [--only bundle/profile,bundle/profile] [--cell B|C]
* Graded by EXIT CODE: 0 all measured cells pass · 1 a cell failed · 2 nothing could be measured.
*
* B · iframe: the partner page iframes the map's own leaf page. PASS = the frame renders real content
* (deep element count), and the HOST is untouched: its URL, title and scroll position.
* C · script: the partner page pastes the manifest's own embed snippet, with ITS OWN token. PASS = the
* element renders, no page error, no failed request to the tree's origin, and every
* api.mapbox.com request carries the HOST's token.
*
* ★ HOW C PROVES THE HOST'S TOKEN IS USED. Handing the partner page the tree's own baked token proves
* nothing — a bundle that ignored the host and fell back to its baked key would pass. So the host
* passes a DISTINCT, well-formed pk token that exists nowhere in the tree. Every api.mapbox.com
* request is intercepted: it must carry that host token (else FAIL: the map used another key), and
* is then forwarded with the real token so the map can still paint. A map drawn with zero Mapbox
* requests is fine for maps that need none; that is reported, not failed.
*/
import { chromium } from 'playwright'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { readFileSync, existsSync } from 'node:fs'
import { join, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const argOf = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
// Default: the seven Research Gallery maps (doxa-maps-spa.meta.json `composes`).
const DEFAULT = JSON.parse(readFileSync(join(ROOT, 'plugin-designs/doxa-maps-spa/doxa-maps-spa.meta.json'), 'utf8')).composes
const ONLY = (argOf('--only', '') ? argOf('--only', '').split(',') : DEFAULT).map((s) => s.trim()).filter(Boolean)
const CELLS = argOf('--cell', '') ? [argOf('--cell', '').toUpperCase()] : ['B', 'C']
const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
if (!existsSync(MANIFEST)) { console.log('MAP-EMBED-CELLS: SKIP — no built tree'); process.exit(2) }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const bakedToken = (() => {
for (const b of manifest.bundles || []) for (const p of b.profiles || []) {
if (!p.iframeUrl) continue
const leaf = join(ROOT, 'public', 'doxa-maps', String(p.iframeUrl).replace(/^\.\//, ''))
if (!existsSync(leaf)) continue
const m = readFileSync(leaf, 'utf8').match(/BAKED_TOKEN\s*=\s*"(pk\.[^"]+)"/)
if (m) return m[1]
}
return ''
})()
// A well-formed pk token that exists nowhere in the tree (Mapbox GL parses the middle segment).
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
const HOST_TOKEN = 'pk.' + b64({ u: 'partnerhost', a: 'embedprobe' }) + '.hostpartnerprobe'
const targets = []
for (const ref of ONLY) {
const [bundle, profile] = ref.split('/')
const b = (manifest.bundles || []).find((x) => x.bundle === bundle)
const p = b && (b.profiles || []).find((x) => x.profile === profile)
if (!b || !p) { targets.push({ ref, missing: true }); continue }
const defined = b.elements || []
const element = defined.includes(p.element) ? p.element : (defined[0] || p.element)
targets.push({ ref, bundle, profile, element, iframeUrl: p.iframeUrl, snippet: (p.embed && p.embed.script) || '' })
}
const freePort = () => new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const port = s.address().port; s.close(() => ok(port)) }) })
const TREE_PORT = await freePort()
const HOST_PORT = await freePort()
const TREE = `http://127.0.0.1:${TREE_PORT}/doxa-maps`
let pageHtml = '<!doctype html><title>host</title><body>nothing</body>'
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT)], { cwd: ROOT, stdio: 'ignore' })
const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); r.end(pageHtml) }).listen(HOST_PORT, 'localhost')
const cleanup = () => { try { tree.kill() } catch (_) {} try { host.close() } catch (_) {} }
await new Promise((r) => setTimeout(r, 900))
if (!(await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false))) {
cleanup(); console.log('MAP-EMBED-CELLS: SKIP — could not serve the built tree'); process.exit(2)
}
const HOST = `http://localhost:${HOST_PORT}/partner.html`
const deepCount = `(() => { let n = 0, c = 0; const w = (r) => { for (const e of r.querySelectorAll('*')) { n++; if (e.tagName === 'CANVAS') c++; if (e.shadowRoot) w(e.shadowRoot) } }; w(document); return { n, c } })()`
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
const rows = []
for (const t of targets) {
if (t.missing) { rows.push({ ref: t.ref, cell: '-', ok: false, why: 'not in the built manifest' }); continue }
for (const cell of CELLS) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
const page = await ctx.newPage()
const errors = [], treeFails = [], mapboxTokens = new Set()
page.on('pageerror', (e) => errors.push(String(e.message || e).slice(0, 140)))
page.on('response', (r) => { if (r.status() >= 400 && r.url().startsWith(`http://127.0.0.1:${TREE_PORT}/`)) treeFails.push(r.status() + ' ' + r.url().replace(`http://127.0.0.1:${TREE_PORT}`, '').slice(0, 90)) })
await ctx.route(/^https:\/\/api\.mapbox\.com\//, async (route) => {
const u = new URL(route.request().url())
const tok = u.searchParams.get('access_token')
if (tok) { mapboxTokens.add(tok === HOST_TOKEN ? 'HOST' : tok === bakedToken ? 'BAKED' : 'OTHER'); if (tok === HOST_TOKEN) u.searchParams.set('access_token', bakedToken) }
await route.continue({ url: u.toString() })
})
let res
if (cell === 'B') {
const src = `${TREE}/${String(t.iframeUrl).replace(/^\.\//, '')}`
pageHtml = `<!doctype html><html><head><meta charset="utf-8"><title>Partner page</title></head><body style="margin:0;font:16px sans-serif">
<h1 style="margin:16px">A partner's own page</h1><iframe id="f" src="${src}" width="100%" height="640" style="border:0"></iframe>
<div style="height:1400px;padding:16px">Partner content below the map.</div></body></html>`
await page.goto(HOST, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(14000)
const frame = page.frames().find((f) => f.url().startsWith(`http://127.0.0.1:${TREE_PORT}/`))
const inside = frame ? await frame.evaluate(deepCount).catch(() => ({ n: 0, c: 0 })) : { n: 0, c: 0 }
const hostState = await page.evaluate(() => ({ url: location.href, title: document.title, y: Math.round(scrollY) }))
const why = []
if (!frame) why.push('iframe never loaded the leaf')
if (inside.n < 40) why.push('frame rendered almost nothing (' + inside.n + ' elements)')
if (hostState.url !== HOST) why.push('host URL changed to ' + hostState.url)
if (hostState.title !== 'Partner page') why.push('host title changed to "' + hostState.title + '"')
if (hostState.y !== 0) why.push('host scrolled to y=' + hostState.y)
if (treeFails.length) why.push('failed tree requests: ' + [...new Set(treeFails)].slice(0, 3).join(' , '))
res = { why, detail: inside.n + ' elements, ' + inside.c + ' canvas in frame; host url/title/scroll unchanged' }
} else {
if (!t.snippet) { rows.push({ ref: t.ref, cell, ok: false, why: 'manifest has no script embed snippet' }); await ctx.close(); continue }
// The manifest's own snippet, made absolute to the tree, with the HOST's token.
let snip = t.snippet.replace(/src="\.\//g, `src="${TREE}/`).replace(/YOUR_MAPBOX_TOKEN/g, HOST_TOKEN)
snip = snip.replace(/("tk"\s*:\s*")[^"]*(")/g, `$1${HOST_TOKEN}$2`)
pageHtml = `<!doctype html><html><head><meta charset="utf-8"><title>Partner page</title></head><body style="margin:0;font:16px sans-serif">
<h1 style="margin:16px">A partner's own page</h1><div style="position:relative;height:640px">${snip}</div>
<div style="height:600px;padding:16px">Partner content below the map.</div></body></html>`
await page.goto(HOST, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(14000)
const st = await page.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(t.element)}); if (!el) return { present: false }
const d = ${deepCount}; return { present: true, defined: !!customElements.get(${JSON.stringify(t.element)}), n: d.n, c: d.c, h: Math.round(el.getBoundingClientRect().height) } })()`)
const why = []
if (!st.present) why.push('snippet did not put <' + t.element + '> on the page')
else {
if (!st.defined) why.push('<' + t.element + '> never registered')
if (st.n < 40) why.push('rendered almost nothing (' + st.n + ' elements)')
if (st.h < 100) why.push('element is ' + st.h + 'px tall')
}
if (mapboxTokens.has('BAKED') || mapboxTokens.has('OTHER')) why.push('Mapbox requests used a token other than the HOST\'s: ' + [...mapboxTokens].join(', '))
if (treeFails.length) why.push('failed tree requests: ' + [...new Set(treeFails)].slice(0, 3).join(' , '))
if (errors.length) why.push('page error: ' + [...new Set(errors)].slice(0, 2).join(' / '))
res = { why, detail: st.n + ' elements, ' + st.c + ' canvas, ' + st.h + 'px; Mapbox token(s): ' + ([...mapboxTokens].join(',') || 'none requested') }
}
rows.push({ ref: t.ref, cell, ok: !res.why.length, why: res.why.join(' | '), detail: res.detail })
await ctx.close()
}
}
await browser.close(); cleanup()
for (const r of rows) console.log((r.ok ? 'PASS ' : 'FAIL ') + r.cell + ' ' + r.ref.padEnd(46) + (r.ok ? r.detail : r.why))
const bad = rows.filter((r) => !r.ok)
console.log(bad.length ? `MAP-EMBED-CELLS: FAIL — ${bad.length} of ${rows.length} cell(s)` : `MAP-EMBED-CELLS: PASS — ${rows.length} cell(s) on a foreign origin`)
process.exit(bad.length ? 1 : 0)

View file

@ -203,6 +203,8 @@ async function measure(width, lang) {
const boxes = cards.map((c) => ({ c, b: c.getBoundingClientRect() }))
let intrusions = 0, overflowing = 0, worst = 0, coveringPreview = 0, worstY = 0
let previewsPainted = 0, previewsBroken = 0, noPreview = 0, unnamedPreview = 0
let notTaped = 0, worstTape = 0, selectsInRow = 0
let headNotTaped = 0, worstHead = 0, mapCovered = 0, worstCover = 0
for (const { c, b } of boxes) {
const row = c.querySelector('.pfs')
if (!row) continue
@ -247,6 +249,69 @@ async function measure(width, lang) {
worstY = Math.max(worstY, Math.round(ib.bottom - rb.top))
}
}
// ★ A STICKER STRADDLES THE EDGE IT TAPES (Driver 2026-09-25, the sticker/banner brief).
// The card is a BANNER displaying a map; each sticker sits centred on a banner edge, part
// on the banner's own white space and part overhanging. Three things were each true while
// this gate was green, so all three are now asserted:
// 1. the row floated 26px BELOW the banner, touching nothing (the shipped state);
// 2. the row sat entirely below the edge, touching at one pixel;
// 3. the row straddled correctly but landed ON THE MAP — "offensive to the map".
// over-preview above catches only a row overlapping the <img> from above.
const frame = c.querySelector('.main')
if (frame) {
const fb = frame.getBoundingClientRect()
// STRADDLE: some of the sticker on the banner, some off it. Requiring a near-even split
// would encode a ratio the Driver explicitly left open ("probably an equal or more
// overlap"); requiring BOTH sides to be non-trivial is the property actually stated.
const onBanner = Math.round(fb.bottom - rb.top)
const offBanner = Math.round(rb.bottom - fb.bottom)
if (onBanner < 4 || offBanner < 4) {
notTaped++
const d = onBanner < 4 ? onBanner : -offBanner
worstTape = Math.abs(d) > Math.abs(worstTape) ? d : worstTape
}
}
// ★ NO NATIVE DROPDOWN IN THE DEFAULT ROW (same ruling). The Driver's reason is not
// cosmetic: "I don't want to add the dropdown for the card because it makes it hard for
// others to have dropdowns too" — a <select> here collides with the host page's own
// selects. The size picker is a disclosure, revealed by the Card Deck button. A <select>
// reappearing in .pfs is the regression this catches.
if (row.querySelector('select')) selectsInRow++
// ★ AND THE HEADER STICKER STRADDLES ITS EDGE TOO. Same requirement, other end of the
// banner, and it was ALSO wrong while everything here was green: MEASURED 35px above the
// banner's top edge and -1px below, i.e. floating over the card touching the banner
// nowhere. Asserting only the footer would have left that in place indefinitely.
const headSticker = c.querySelector('.tape-h')
if (frame && headSticker) {
const hb = headSticker.getBoundingClientRect()
const fb2 = frame.getBoundingClientRect()
const onB = Math.round(hb.bottom - fb2.top)
const offB = Math.round(fb2.top - hb.top)
if (onB < 4 || offB < 4) {
headNotTaped++
const d = onB < 4 ? onB : -offB
worstHead = Math.abs(d) > Math.abs(worstHead) ? d : worstHead
}
}
// ★ NOTHING TOUCHES THE MAP. The brief is unambiguous and is the reason the banner grew
// at all: "the preview banner to showcase a map should not have anything overlapping the
// map because the map needs to be fully visible." Measured on the first straddle attempt:
// header over the map by 6px, footer by 8px — a correct straddle onto a banner with too
// little white space reserved. THIS is the assertion that catches that, and neither the
// straddle checks above nor over-preview can: over-preview only sees a row whose top is
// above the image bottom, which is a different geometry from a sticker landing on it.
if (img) {
const ib3 = img.getBoundingClientRect()
if (ib3.height > 2) {
const byFoot = Math.round(ib3.bottom - rb.top)
if (byFoot > 0) { mapCovered++; worstCover = Math.max(worstCover, byFoot) }
if (headSticker) {
const hb2 = headSticker.getBoundingClientRect()
const byHead = Math.round(hb2.bottom - ib3.top)
if (byHead > 0) { mapCovered++; worstCover = Math.max(worstCover, byHead) }
}
}
}
for (const o of boxes) {
if (o.c === c) continue
const ox = Math.min(rb.right, o.b.right) - Math.max(rb.left, o.b.left)
@ -295,7 +360,8 @@ async function measure(width, lang) {
return { cards: cards.length, rows: cards.filter((c) => c.querySelector('.pfs')).length,
overflowing, worst, intrusions, coveringPreview, worstY,
previewsPainted, previewsBroken, noPreview, unnamedPreview, clipped: clipped.length,
controls: ctrls.length, tiny: tiny.length, perRow, cardW }
controls: ctrls.length, tiny: tiny.length, perRow, cardW,
notTaped, worstTape, selectsInRow, headNotTaped, worstHead, mapCovered, worstCover }
})()`)
await page.close()
return { width, lang, hover, reach, ...out }
@ -315,6 +381,10 @@ for (const r of results) {
+ ` · overflow ${r.overflowing}${r.worst ? ` (${r.worst}px)` : ''}`
+ ` · neighbour ${r.intrusions}`
+ ` · over-preview ${r.coveringPreview}${r.worstY ? ` (${r.worstY}px)` : ''}`
+ ` · off-frame ${r.notTaped}${r.worstTape ? ` (${r.worstTape > 0 ? '+' : ''}${r.worstTape}px)` : ''}`
+ ` · selects ${r.selectsInRow}`
+ ` · head-off ${r.headNotTaped}${r.worstHead ? ` (${r.worstHead > 0 ? '+' : ''}${r.worstHead}px)` : ''}`
+ ` · map-covered ${r.mapCovered}${r.worstCover ? ` (${r.worstCover}px)` : ''}`
+ ` · previews ${r.previewsPainted} painted`
+ `${r.previewsBroken ? `/${r.previewsBroken} BROKEN` : ''}${r.noPreview ? `/${r.noPreview} none` : ''}`
+ ` · max ${r.perRow}/row @ ${r.cardW}px`
@ -362,6 +432,39 @@ for (const r of results) {
fails.push(`${at}: ${r.previewsBroken} preview image(s) failed to load (naturalWidth 0) — `
+ 'a broken preview is not something the geometry checks can see')
}
// ★ THE OTHER EDGE OF over-preview. That assertion catches a row sitting too HIGH; this one
// catches it sitting too LOW, which is the state that actually shipped and that nothing here
// could see: MEASURED frame bottom 426, row top 452, and a full green run.
if (r.notTaped) {
fails.push(`${at}: ${r.notTaped} print row(s) do not STRADDLE the banner's bottom edge `
+ `(worst ${r.worstTape > 0 ? '+' : ''}${r.worstTape}px on one side; positive = too little `
+ 'on the banner, negative = too little overhanging). The footer sticker sits centred on '
+ 'that edge, part on the banner\'s white space and part hanging onto the page below — '
+ '"slight overlap over the banner, but not over the content of the banner". A row '
+ 'floating clear of the banner is as wrong as one covering the map.')
}
// ★ THE ONE THE DRIVER CARES ABOUT MOST. "The preview banner to showcase a map should not
// have anything overlapping the map because the map needs to be fully visible."
if (r.mapCovered) {
fails.push(`${at}: ${r.mapCovered} sticker(s) OVERLAP the map preview (worst ${r.worstCover}px). `
+ 'The banner\'s header and footer stickers straddle the banner\'s edges; the map inside is '
+ 'never to be touched — "if I put a piece of tape over the text it would be offensive to '
+ 'the map". Reserve more banner white space via --banner-pad-top/--banner-pad-bottom on '
+ '.main rather than moving the sticker: a sticker centred on the edge puts HALF its '
+ 'height on the banner, so the padding must exceed that half.')
}
if (r.headNotTaped) {
fails.push(`${at}: ${r.headNotTaped} header sticker(s) do not straddle the banner's top edge `
+ `(worst ${r.worstHead > 0 ? '+' : ''}${r.worstHead}px on one side). A sticker sits ON the `
+ 'edge it tapes, part on the banner and part overhanging — measured 35px above / -1px '
+ 'below before this was asserted, i.e. floating free of the banner entirely.')
}
if (r.selectsInRow) {
fails.push(`${at}: ${r.selectsInRow} native <select>(s) in the print row. The card-stock `
+ 'picker is a DISCLOSURE behind the Card Deck button, not a control in the default row — '
+ '"I don\'t want to add the dropdown for the card because it makes it hard for others to '
+ 'have dropdowns too." A <select> here also collides with the host page\'s own dropdowns.')
}
// EVERY card falling back to the placeholder means the previews are gone. MEASURED by aborting
// every previews/** request: the product degrades WELL — all 27 cards swap to a .ph placeholder
// reading "poster not generated yet", with no broken-image icons and no <img> left at all. That

View file

@ -72,6 +72,13 @@ const MARKERS = [
component: 'library/composables/useMapInstance.js',
sourceRe: /if \(!element\) return\n\s*const root = element\.getRootNode\(\)/,
builtRe: /if\(!([\w$]+)\)return;const ([\w$]+)=\1\.getRootNode\(\);if\(!\(\2 instanceof ShadowRoot\)\)return/,
// ★ NOT EVERY CONSUMER COMPILES THE GUARDED FUNCTION. ensureEngineCSS is only called by
// initializeMap; a bundle that imports pieces of useMapInstance (ensureEngineScript, engineGlobal…)
// but never initializeMap gets it TREE-SHAKEN AWAY, so the guard can never appear there — and the
// race cannot happen there either. Reported by upg100 (my-upg-100-list) 2026-09-25: 3 of 12
// bundles were false negatives. `staleRe` is the UNGUARDED shape: a bundle is MISSING the fix only
// if it still compiles ensureEngineCSS without the guard; with neither shape it is NOT AFFECTED.
staleRe: /const ([\w$]+)=([\w$]+)\.getRootNode\(\);if\(!\(\1 instanceof ShadowRoot\)\)return/g,
marker: 'if (!element) return (ensureEngineCSS)',
landed: 'studio, 2026-09-25',
why: 'a map whose pane unmounts mid-initialise no longer throws "reading getRootNode" (check-map-unmount-mid-init)',
@ -80,6 +87,13 @@ const MARKERS = [
// A marker is a literal (`marker`, matched with includes) unless the entry supplies regexes.
const inSource = (m, src) => (m.sourceRe ? m.sourceRe.test(src) : src.includes(m.marker))
const inBuilt = (m, js) => (m.builtRe ? m.builtRe.test(js) : js.includes(m.marker))
// With a staleRe: does the bundle compile the OLD shape — an occurrence NOT immediately preceded by
// the guard? (The guarded form contains the old shape as a substring, hence the look-behind.)
const hasStale = (m, js) => {
if (!m.staleRe) return true
for (const x of js.matchAll(new RegExp(m.staleRe.source, 'g'))) if (!/return;$/.test(js.slice(Math.max(0, x.index - 24), x.index))) return true
return false
}
if (!existsSync(TREE)) {
console.log(`SHARED-REACHED-BUNDLES: SKIP — no built tree at ${TREE}.`)
@ -158,19 +172,23 @@ for (const m of MARKERS) {
const have = []
const missing = []
const unbuilt = []
const unaffected = []
for (const bundle of consumers) {
const built = join(TREE, bundle, `${bundle}.js`)
if (!existsSync(built)) { unbuilt.push(bundle); continue }
const js = readFileSync(built, 'utf8')
;(inBuilt(m, js) ? have : missing).push(bundle)
if (inBuilt(m, js)) have.push(bundle)
else if (hasStale(m, js)) missing.push(bundle)
else unaffected.push(bundle)
}
console.log(` ${m.id} — ${m.why}`)
console.log(` landed ${m.landed}; ${have.length} of ${have.length + missing.length} built `
+ `consumer(s) carry it${unbuilt.length ? `, ${unbuilt.length} not built` : ''}`)
+ `consumer(s) carry it${unaffected.length ? ` (${unaffected.length} not affected)` : ''}${unbuilt.length ? `, ${unbuilt.length} not built` : ''}`)
if (have.length) console.log(` ✓ ${have.join(', ')}`)
if (missing.length) console.log(` ✗ ${missing.join(', ')}`)
if (unbuilt.length) console.log(` · not built: ${unbuilt.join(', ')}`)
if (unaffected.length) console.log(` – not affected (the changed code is not compiled in): ${unaffected.join(', ')}`)
if (missing.length) {
fails.push(`${m.id}: ${missing.length} bundle(s) do NOT carry "${m.why}" — ${missing.join(', ')}`)

View file

@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* check-vision-card-numbers — the DOXA Vision Card shows the numbers the DATA gives, nothing typed.
*
* USAGE: node internal/check-vision-card-numbers.mjs (data only, no browser, ~2 s)
* node internal/check-vision-card-numbers.mjs --url <card> (also read the RENDERED card)
* Exit 0 = every number matches · 1 = a mismatch.
*
* INDEPENDENT RECOMPUTATION. It does not import build-vision-funnel.mjs: it re-reads the raw CSV with
* its own column lookup and re-applies the published rules (HOW-THE-NUMBERS-ARE-COUNTED.md) to the
* saved DOXA list, then compares with (1) data/people-groups/vision-funnel.json, which the card
* compiles in, and (2) with --url, the numbers actually painted in the card's bars. A typed-in
* number, a stale funnel file or a rendering bug each makes it fail.
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const text = readFileSync(resolve(ROOT, 'data/people-groups/people_groups.csv'), 'utf8')
// Own CSV reader: split records on newlines outside quotes, fields on commas outside quotes.
const records = []; { let cur = ''; let q = false; for (const ch of text) { if (ch === '"') q = !q; if (ch === '\n' && !q) { records.push(cur); cur = '' } else cur += ch } if (cur.trim()) records.push(cur) }
const split = (line) => { const out = []; let f = ''; let q = false; for (let i = 0; i < line.length; i++) { const c = line[i]; if (c === '"') { if (q && line[i + 1] === '"') { f += '"'; i++ } else q = !q } else if (c === ',' && !q) { out.push(f); f = '' } else if (c !== '\r') f += c } out.push(f); return out }
const head = split(records[0]).map((h) => h.replace(/^/, ''))
const col = (n) => head.indexOf(n)
const L = col('LostnessPriority'), R3 = col('ROP3Code'), ISO = col('ISOAlpha3')
const data = records.slice(1).map(split).filter((f) => f.length === head.length)
const expect = {
all: data.length,
unreached: data.filter((f) => ['0', '1', '2'].includes(f[L])).length,
frontier: data.filter((f) => f[L] === '0').length,
}
const snap = JSON.parse(readFileSync(resolve(ROOT, 'data/people-groups/doxa-groups-snapshot.json'), 'utf8'))
const lost = new Map(data.map((f) => [f[R3] + '|' + f[ISO], f[L]]))
expect.doxa = snap.groups.length
expect.doxaFrontier = snap.groups.filter((g) => lost.get(g.rop3 + '|' + g.country) === '0').length
const funnel = JSON.parse(readFileSync(resolve(ROOT, 'data/people-groups/vision-funnel.json'), 'utf8'))
const got = Object.fromEntries(funnel.steps.map((s) => [s.id, s.groups]))
got.doxaFrontier = funnel.steps.find((s) => s.id === 'doxa').frontier
const bad = []
for (const k of Object.keys(expect)) {
const ok = expect[k] === got[k]
console.log((ok ? 'ok ' : 'FAIL ') + k.padEnd(13) + ' recomputed ' + String(expect[k]).padStart(6) + ' vision-funnel.json ' + got[k])
if (!ok) bad.push(k)
}
const i = process.argv.indexOf('--url')
if (i > -1) {
const { chromium } = await import('playwright')
const b = await chromium.launch()
const p = await b.newPage({ viewport: { width: 900, height: 1000 } })
await p.goto(process.argv[i + 1], { waitUntil: 'networkidle' })
await p.waitForTimeout(1500)
const shown = await p.evaluate(() => { const el = document.querySelector('doxa-vision-card'); const r = el && el.shadowRoot; return r ? [...r.querySelectorAll('.vc-n')].map((n) => n.textContent.trim()) : [] })
const note = await p.evaluate(() => { const r = document.querySelector('doxa-vision-card')?.shadowRoot; return r ? r.querySelector('.vc-step-doxa .vc-note')?.textContent || '' : '' })
await b.close()
const want = [expect.all, expect.unreached, expect.frontier, expect.doxa].map((n) => new Intl.NumberFormat('en').format(n))
const okBars = JSON.stringify(shown) === JSON.stringify(want)
console.log((okBars ? 'ok ' : 'FAIL ') + 'rendered bars ' + JSON.stringify(shown) + (okBars ? '' : ' expected ' + JSON.stringify(want)))
const okNote = note.includes(new Intl.NumberFormat('en').format(expect.doxaFrontier))
console.log((okNote ? 'ok ' : 'FAIL ') + 'rendered DOXA note "' + note.trim() + '"')
if (!okBars) bad.push('rendered bars'); if (!okNote) bad.push('rendered note')
}
console.log(bad.length ? 'VISION-CARD-NUMBERS: FAIL — ' + bad.join(', ') : 'VISION-CARD-NUMBERS: PASS — every number matches the data')
process.exit(bad.length ? 1 : 0)

View file

@ -1438,6 +1438,8 @@ function main() {
// 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',
// …and its compact cut (build-points.mjs): what all-people-groups actually fetches, 0.74 MB.
'people-groups/people-groups-points.json',
// vendor — DataTable/TabulatorTable inject Tabulator from assetsBase at runtime.
'vendor/tabulator/tabulator.min.js',
'vendor/tabulator/tabulator.min.css',

View file

@ -159,12 +159,26 @@ async function shot(name, path, {
// frame and loads no tiles of its own).
if (mapboxReferer) await ctx.setExtraHTTPHeaders({ Referer: 'https://doxa.life/' })
const page = await ctx.newPage()
// ★ WAIT FOR THE MAP TO FINISH LOADING, NOT JUST FOR THE PAGE TO EXIST. The element-count guard
// below proves the UI rendered; it says nothing about map tiles and data, which arrive later. Shots
// taken on a fixed timer showed the Big Picture globe as a bare white ball and the UPG-100 map
// without its resources. So every request is tracked, and the capture waits until nothing has
// been in flight for QUIET_MS (capped at QUIET_MAX_MS, and reported if the cap is hit).
let inflight = 0, lastNet = Date.now()
const track = (d) => (req) => { if (/^(websocket|eventsource|media)$/.test(req.resourceType())) return; inflight += d; lastNet = Date.now() }
page.on('request', track(1)); page.on('requestfinished', track(-1)); page.on('requestfailed', track(-1))
const url = (/^https?:/.test(path) ? path : BASE + path) + (tokenFree ? '' : (path.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TK))
try {
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
if (waitFor) await page.waitForSelector(waitFor, { timeout: 20000 })
if (before) await before(page)
await page.waitForTimeout(wait)
const QUIET_MS = 4000, QUIET_MAX_MS = 60000, t0 = Date.now()
while (!(inflight <= 0 && Date.now() - lastNet >= QUIET_MS)) {
if (Date.now() - t0 > QUIET_MAX_MS) { console.log(` WARN ${name}: network never went quiet in ${QUIET_MAX_MS / 1000}s (${inflight} in flight) — check the image`); break }
await page.waitForTimeout(250)
}
await page.waitForTimeout(1500) // let the last tiles paint
// ★ READINESS GUARD — a screenshot of a blank page is still a valid PNG, so the file
// existing proves nothing. Walk shadow roots (a composite's content is NOT in the light

View file

@ -76,15 +76,35 @@ const CONCURRENCY = Math.max(
) || 1
)
// The rendezvous every agent's browser gate queues on. It must be ONE path for all of them or
// the queue is not a queue, so it is an ENV VAR with a product-named default: the shared file
// several agents' shells already export lives outside this repo and carries an internal code
// name, which check:no-codename correctly refuses to let a handed-over repo carry. Export
// BROWSER_GATE_LOCK to join whatever rendezvous the rest of the box is using.
const BROWSER_LOCK = process.env.BROWSER_GATE_LOCK || '/tmp/doxa-maps-browser-gate.lock'
const LOAD_MAX = Number(process.env.LOAD_MAX || 12)
const QUIET_WAIT_MAX = Number(process.env.QUIET_WAIT_MAX || 900)
// ★ TWO LOCKS, IN A FIXED ORDER, AND THAT IS NOT BELT-AND-BRACES — IT IS A NAME PROBLEM.
// The rendezvous must be ONE path for everyone or the queue is not a queue. The agents' own probe
// scripts take a lock whose filename carries an internal code name, and check:no-codename refuses
// (correctly) to let a repo we are handing over contain that word. So this takes BOTH: the repo's
// product-named lock, and whatever path the environment names — always in that order, so two
// processes can never take them in opposite orders and deadlock. Either file alone still
// serialises everything that uses it; taking both is what makes gates and hand-run probes queue
// against each other without the repo having to spell a name it must not ship.
const BROWSER_LOCK = '/tmp/doxa-maps-browser-gate.lock'
const EXTRA_LOCK = process.env.BROWSER_GATE_LOCK && process.env.BROWSER_GATE_LOCK !== BROWSER_LOCK
? process.env.BROWSER_GATE_LOCK
: ''
// ★ THE LOAD WAIT WAS COSTING MORE THAN IT SAVED. It was written at LOAD_MAX 12 when the risk was
// seven agents starting chrome at once; the FLOCK below is what actually prevents that — one
// browser gate at a time, across every agent on the box. The load check is belt-and-braces on top
// of a belt. MEASURED 2026-09-25 with several agents working: 1-min load sits at 36-50 all day, so
// 12 is unreachable and every browser gate simply waited out its full 900s cap before running
// anyway. That is a 15-minute tax per wave for a guard that never opens, and its practical effect
// is that people stop running browser gates. Raised to a level that still catches a genuinely
// wedged box, with a short cap so a wave is never held for long. Both still override by env.
// Scaled to the machine rather than a constant: a load of 40 means something different on 8 cores
// than on 64. Five runnable processes per core is busy-but-working; past that the box is thrashing
// and a browser gate would be measuring the thrash.
const LOAD_MAX = Number(process.env.LOAD_MAX || cpus().length * 5)
const QUIET_WAIT_MAX = Number(process.env.QUIET_WAIT_MAX || 120)
const HAS_FLOCK = spawnSync('sh', ['-c', 'command -v flock'], { stdio: 'ignore' }).status === 0
const LOCK_WAIT_S = Number(process.env.BROWSER_LOCK_WAIT || 900)
// EX_TEMPFAIL. Anything but 0 or 1, so it cannot be confused with a gate's own verdict.
const LOCK_BUSY_CODE = 75
/**
* Does this step drive a real browser? Asked of the GATE FILE, not of a hand-kept list:
@ -157,7 +177,18 @@ function buildSteps() {
// wait-for-quiet.sh takes the same lock), so this queues rather than dogpiles. -w 1200
// caps the wait so a crashed holder cannot wedge a wave forever, and static gates —
// parse, sri, hygiene, the twenty that are node-startup — are left completely free.
return { ...step, browser: true, cmd: 'flock', args: ['-w', '1200', BROWSER_LOCK, 'bun', 'run', name] }
// flock can exec flock: nesting is how one command waits on both rendezvous points.
// ★ -E 75: A LOCK TIMEOUT IS NOT A GATE FAILURE, AND flock CANNOT SAY SO BY DEFAULT.
// Without it flock exits 1 on timeout — the same code a failing gate uses — and a wave that
// simply could not get the browser lock prints ✗ against five product gates. MEASURED: five
// browser gates each "failed" at exactly 1200064ms, which is the timeout to the millisecond
// and not a verdict about the product at all. 75 (EX_TEMPFAIL) is distinct, so the runner can
// report "could not take the lock" as the precondition it is.
const lockArgs = ['-w', String(LOCK_WAIT_S), '-E', String(LOCK_BUSY_CODE)]
const args = EXTRA_LOCK
? [...lockArgs, BROWSER_LOCK, 'flock', ...lockArgs, EXTRA_LOCK, 'bun', 'run', name]
: [...lockArgs, BROWSER_LOCK, 'bun', 'run', name]
return { ...step, browser: true, cmd: 'flock', args }
})
}
// A bundle is a plugin-designs/<name>/ directory — the same selector build:unlocked used
@ -217,9 +248,12 @@ async function main() {
// check:packets-all SKIPped on every CI build for exactly this reason and nobody
// could see it, because the wave reported it green. An always-taken SKIP is
// indistinguishable from an unwired gate unless the runner says so.
const skipped = /(^|\n)\s*[A-Z][A-Z0-9-]*:\s*SKIP\b/.test(r.out)
// A gate that never ran because the browser lock stayed busy is UNMEASURED, not failed.
const lockBusy = r.browser && r.code === LOCK_BUSY_CODE
const skipped = lockBusy || /(^|\n)\s*[A-Z][A-Z0-9-]*:\s*SKIP\b/.test(r.out)
if (lockBusy) r.out = (r.out || '') + `\n (could not take the browser lock within ${LOCK_WAIT_S}s — not a product finding)`
console.log(
` ${r.code !== 0 ? '✗' : skipped ? '⊘' : '✓'} ${r.name.padEnd(26)} ${String(r.ms).padStart(7)}ms` +
` ${skipped ? '⊘' : r.code !== 0 ? '✗' : '✓'} ${r.name.padEnd(26)} ${String(r.ms).padStart(7)}ms` +
(r.code === 0 ? '' : ` exit ${r.code}`)
)
r.skipped = skipped
@ -229,7 +263,7 @@ async function main() {
const wall = Date.now() - t0
const serial = results.reduce((a, r) => a + r.ms, 0)
const failed = results.filter((r) => r.code !== 0)
const failed = results.filter((r) => r.code !== 0 && !(r.browser && r.code === LOCK_BUSY_CODE))
if (VERBOSE) for (const r of results) if (r.out.trim()) console.log(`\n───── ${r.name} ─────\n${r.out}`)
// A failure must be readable in full: these gates explain themselves in their output, and

View file

@ -20,6 +20,9 @@
* useMapData when normalizing). If facets aren't ready yet, we render a
* placeholder so the panel never blocks initial paint.
*/
import { tStatic } from '@map/i18n/index.js'
// See ResearchMapSideMenu for why this reads the catalogue directly rather than through useI18n.
const L = (key, en) => tStatic('controls.' + key) || en
import { inject, computed, watch, ref } from 'vue'
import { useShadowStyles } from '../composables/useShadowStyles.js'
@ -141,37 +144,37 @@ useShadowStyles(`
<div class="rm-filter">
<!-- Population range ──────────────────────────────────────────────── -->
<div class="field">
<label>Population</label>
<label>{{ L('population', 'Population') }}</label>
<div class="rm-pop-row">
<input type="number" min="0" placeholder="min" :value="popMin" @input="popMin = $event.target.value" />
<input type="number" min="0" placeholder="max" :value="popMax" @input="popMax = $event.target.value" />
<input type="number" min="0" :placeholder="L('min', 'min')" :value="popMin" @input="popMin = $event.target.value" />
<input type="number" min="0" :placeholder="L('max', 'max')" :value="popMax" @input="popMax = $event.target.value" />
</div>
</div>
<!-- Engagement / adoption status ──────────────────────────────────── -->
<div class="field">
<label>Engagement</label>
<label>{{ L('engagement', 'Engagement') }}</label>
<select :value="engagement" @change="engagement = $event.target.value">
<option value="any">Any</option>
<option value="engaged">Engaged</option>
<option value="unengaged">Unengaged</option>
<option value="engaged">{{ L('engaged', 'Engaged') }}</option>
<option value="unengaged">{{ L('unengaged', 'Unengaged') }}</option>
</select>
</div>
<div class="field">
<label>Adoption</label>
<label>{{ L('adoption', 'Adoption') }}</label>
<select :value="adoption" @change="adoption = $event.target.value">
<option value="any">Any</option>
<option value="adopted">Adopted</option>
<option value="unadopted">Unadopted</option>
<option value="adopted">{{ L('adopted', 'Adopted') }}</option>
<option value="unadopted">{{ L('unadopted', 'Unadopted') }}</option>
</select>
</div>
<!-- Language multi-select ─────────────────────────────────────────── -->
<div class="field">
<label>Language</label>
<input type="search" v-model="langSearch" placeholder="Search languages…" />
<label>{{ L('language', 'Language') }}</label>
<input type="search" v-model="langSearch" :placeholder="L('search_languages', 'Search languages…')" />
<div class="rm-chips">
<span v-if="!filteredLangs.length" class="rm-empty">No languages loaded yet</span>
<span v-if="!filteredLangs.length" class="rm-empty">{{ L('no_languages', 'No languages loaded yet') }}</span>
<button
v-for="lang in filteredLangs"
:key="lang"
@ -184,10 +187,10 @@ useShadowStyles(`
<!-- Religion multi-select ─────────────────────────────────────────── -->
<div class="field">
<label>Religion</label>
<input type="search" v-model="relSearch" placeholder="Search religions…" />
<label>{{ L('religion', 'Religion') }}</label>
<input type="search" v-model="relSearch" :placeholder="L('search_religions', 'Search religions…')" />
<div class="rm-chips">
<span v-if="!filteredRels.length" class="rm-empty">No religions loaded yet</span>
<span v-if="!filteredRels.length" class="rm-empty">{{ L('no_religions', 'No religions loaded yet') }}</span>
<button
v-for="rel in filteredRels"
:key="rel"
@ -201,7 +204,7 @@ useShadowStyles(`
<!-- Footer: active count + reset link ─────────────────────────────── -->
<div class="rm-filter-footer">
<span class="rm-pill">{{ activeCount }} active</span>
<button class="rm-link-btn" @click="resetAll">Reset all</button>
<button class="rm-link-btn" @click="resetAll">{{ L('reset_all', 'Reset all') }}</button>
</div>
</div>
</template>

View file

@ -32,15 +32,24 @@ defineProps({
})
const emit = defineEmits(['close', 'open-poster', 'reload-map'])
import { tStatic } from '@map/i18n/index.js'
// ★ THIS PANEL WAS ENGLISH IN ALL ELEVEN LOCALES. Measured on the composite under ?lang=ru: every
// Latin-script string left on the page belonged to this menu and its filter sibling — the map
// around them was fully translated. Read through tStatic rather than useI18n: these components are
// reachable from mounts that install no i18n plugin, and an unguarded useI18n() throws and takes
// the subtree with it (the defect that blanked people-groups-grid, fixed in 6603dc17). English
// stays as the last argument, so a missing key degrades to today's text rather than to a key name.
const L = (key, en) => tStatic('controls.' + key) || en
const mapStore = inject('mapStore')
const uiStore = inject('uiStore')
// ─── Cluster controls ────────────────────────────────────────────────────────
const CLUSTER_MODES = [
{ id: 'off', label: 'Off', desc: 'Show every pin individually' },
{ id: 'mapbox', label: 'Native', desc: 'Mapbox circle clusters (fastest)' },
{ id: 'mst', label: 'MST', desc: 'Minimum-spanning-tree affinity webs' },
{ id: 'network', label: 'Network', desc: 'Nearest-neighbor connection lines' }
{ id: 'off', label: L('off', 'Off'), desc: L('off_desc', 'Show every pin individually') },
{ id: 'mapbox', label: L('native', 'Native'), desc: L('native_desc', 'Mapbox circle clusters (fastest)') },
{ id: 'mst', label: L('mst', 'MST'), desc: L('mst_desc', 'Minimum-spanning-tree affinity webs') },
{ id: 'network', label: L('network', 'Network'), desc: L('network_desc', 'Nearest-neighbor connection lines') }
]
const clusterMode = computed({
get: () => mapStore?.clusteringMode ?? 'off',
@ -87,14 +96,14 @@ useShadowStyles(`
<div class="rm-side-backdrop" :class="{ open: isOpen }" @click="emit('close')" />
<aside class="rm-side-drawer" :class="{ open: isOpen }" role="dialog" aria-label="Research map controls">
<header class="rm-side-header">
<h3 class="rm-side-title">Research controls</h3>
<h3 class="rm-side-title">{{ L('research_controls', 'Research controls') }}</h3>
<button class="rm-side-close" aria-label="Close" @click="emit('close')">×</button>
</header>
<div class="rm-side-body">
<!-- Clustering ─────────────────────────────────────────────────── -->
<div class="rm-section">
<h4>Clustering</h4>
<h4>{{ L('clustering', 'Clustering') }}</h4>
<div class="rm-cluster-grid">
<button
v-for="m in CLUSTER_MODES"
@ -108,7 +117,7 @@ useShadowStyles(`
</button>
</div>
<div v-if="clusterMode !== 'off'" class="rm-slider-row">
<label for="rm-pin-op">Pin dim</label>
<label for="rm-pin-op">{{ L('pin_dim', 'Pin dim') }}</label>
<input
id="rm-pin-op"
type="range"
@ -124,14 +133,14 @@ useShadowStyles(`
<!-- Filter panel slot ──────────────────────────────────────────── -->
<div class="rm-section">
<h4>Filters</h4>
<h4>{{ L('filters', 'Filters') }}</h4>
<slot />
</div>
</div>
<footer class="rm-side-footer">
<button class="rm-btn" @click="emit('open-poster')">Export poster…</button>
<button class="rm-btn ghost" @click="emit('reload-map')">Reload data</button>
<button class="rm-btn" @click="emit('open-poster')">{{ L('export_poster', 'Export poster…') }}</button>
<button class="rm-btn ghost" @click="emit('reload-map')">{{ L('reload_data', 'Reload data') }}</button>
</footer>
</aside>
</template>

View file

@ -469,18 +469,17 @@ const tabsForMobile = computed(() => {
const treeTitleForMobile = computed(() => {
// A profile-supplied title names the MAP; the taxonomy names only the tab.
if (props.title) return props.title
if (_isFlatTabMobile.value) return 'Legend'
if (props.legendType === 'affinity-block') return 'People Groups'
if (props.legendType === 'doxa-regions') return 'WAGF Regions'
return 'Languages'
if (_isFlatTabMobile.value) return t('legend.titleBar.legend')
if (props.legendType === 'affinity-block') return t('legend.titleBar.peopleGroups')
if (props.legendType === 'doxa-regions') return t('legend.titleBar.wagfRegions')
return t('legend.titleBar.languages')
})
const treeColumnLabelForMobile = computed(() => {
if (props.columnLabel !== null) return props.columnLabel
if (props.legendType === 'religion') return 'UUPG Religions'
if (props.legendType === 'prayer') return 'Prayer Status'
if (props.legendType === 'engagement') return 'Engagement Status'
if (props.legendType === 'adoption') return 'Adoption Status'
if (_isFlatTabMobile.value) return 'Status'
// From the catalog (legend.colLabel.*), translated in all 11 locales; these were English
// literals, so a phone in ar/zh/ru/hi showed "Prayer Status" over translated rows.
if (['religion', 'prayer', 'engagement', 'adoption'].includes(props.legendType)) return t('legend.colLabel.' + props.legendType)
if (_isFlatTabMobile.value) return t('legend.colLabel.status')
return ''
})

View file

@ -187,9 +187,28 @@ function storePerRow(n) {
}
// grid-template-columns, or nothing at all when the count is 0 — an empty string leaves the
// stylesheet's auto-fill rule in charge rather than overriding it with something equivalent.
const gridStyle = computed(() => perRow.value > 0
? { gridTemplateColumns: `repeat(${perRow.value}, minmax(0, 1fr))` }
: {})
// ⚠ THE PREFERENCE IS CLAMPED BY WHAT THE VIEWPORT CAN ACTUALLY HOLD.
// An inline style beats the stylesheet, so `repeat(2, minmax(0,1fr))` overrode the CSS rule's
// `minmax(210px,1fr)` at EVERY width — including 390px, where two columns give a ~162px card.
// Reported from a 390x844 phone shot: two posters per row, where the CSS alone would have
// given one. The stored preference is the Driver's and must survive; it is a preference for
// DESKTOP density, not an instruction to overflow a phone.
// 210px is the same minimum the stylesheet's auto-fill uses, so the clamp agrees with the rule
// it is overriding rather than inventing a second breakpoint.
const CARD_MIN_PX = 210
const viewportW = ref(typeof window !== 'undefined' ? window.innerWidth : 1280)
if (typeof window !== 'undefined') {
const onResize = () => { viewportW.value = window.innerWidth }
window.addEventListener('resize', onResize)
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
}
const gridStyle = computed(() => {
if (!(perRow.value > 0)) return {}
// The wall's own padding either side; err toward FEWER columns rather than a clipped card.
const usable = Math.max(0, viewportW.value - 36)
const fits = Math.max(1, Math.floor(usable / CARD_MIN_PX))
return { gridTemplateColumns: `repeat(${Math.min(perRow.value, fits)}, minmax(0, 1fr))` }
})
const plural = computed(() => props.itemNounPlural || `${props.itemNoun}s`)
const base = computed(() => String(props.assetsBase || './assets').replace(/\/+$/, ''))
@ -409,6 +428,31 @@ const prints = usePosterPrints({
const canPrintRows = computed(() => typeof props.records === 'function')
const previewIsControl = computed(() => props.previewAction === 'print')
// ── CARD-DECK DISCLOSURE ────────────────────────────────────────────────────
// Which card currently has its size row revealed, by item id. Holding an ID
// rather than a boolean-per-card means opening one CLOSES the others for free:
// two size rows open at once would overlap in the grid gap, and there is no
// reading of the Driver's request where two are wanted.
const deckOpenFor = ref(null)
function toggleDeck(id, ev) {
deckOpenFor.value = deckOpenFor.value === id ? null : id
// Tell the size row how far down to start: the print row's REAL height, which is
// one line in en and two in the long-label locales. Walking up from the clicked
// button to its own .pfs keeps this correct per card rather than per wall.
const pfs = ev?.currentTarget?.closest?.('.pfs')
if (pfs) pfs.parentElement?.style.setProperty('--pstocks-gap', `${pfs.offsetHeight}px`)
}
// Choosing a size IS the print command — the reader already said "print the
// cards" by opening this row, so a second confirm step would be a click with
// nothing behind it. prints.stock reads pickedStock through a getter, so setting
// it here is what the deck prints at.
function printDeckAt(item, stockId) {
pickedStock.value = stockId
deckOpenFor.value = null
prints.printCardDeck(item)
}
function downloadPacket(item) {
// Still the honest "not yet" when this wall has no records provider: NOT an
// error — unbuilt, not broken. phase:'error' reads to a user as "something went
@ -479,12 +523,21 @@ function download(blob, name) {
function dismissExport() { exportState.value = null }
useShadowStyles(
'.wall{position:absolute;inset:0;overflow-y:auto;padding:22px 18px 44px;' +
// --wall-pad-top is read back by .sec-head::before to cover the scroll-padding strip
// above a pinned header (see there). Declared as a var so the two can never drift:
// typing 22px in both places is how that gap would silently re-open.
'.wall{--wall-pad-top:22px;position:absolute;inset:0;overflow-y:auto;' +
'padding:var(--wall-pad-top) 18px 44px;' +
"background:#f6f7f9;color:#0f1419;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}" +
'.wall.is-dark{background:#0a1410;color:#e5e7eb}' +
// Embedded: normal flow, no pane background, no pane padding. inset:auto matters as much
// as position:static — leaving inset set keeps the box stretched even once static.
'.wall--embedded{position:static;inset:auto;overflow-y:visible;padding:0;background:transparent}' +
// ⚠ --wall-pad-top MUST go to 0 here too, not just the padding. Embedded, the wall is not
// the scroll container — the HOST page is — so there is no scroll-padding strip to cover,
// and a 22px cover bar would paint the wall's background over the host's own content
// above each header. The var and the padding are one decision, set in one place.
'.wall--embedded{--wall-pad-top:0px;position:static;inset:auto;overflow-y:visible;' +
'padding:0;background:transparent}' +
'.wall--embedded.is-dark{background:transparent}' +
'.wall-h{margin:0 0 6px;font-size:19px;font-weight:700;text-align:center}' +
'.wall-tag{margin:0 auto 12px;width:fit-content;padding:3px 11px;border-radius:999px;' +
@ -497,11 +550,16 @@ useShadowStyles(
// GAP must clear the OVERHANGING TAPES, not just the card boxes: the header tape
// sits at top:-11px and the footer fieldset at bottom:-14px, so a 26px row gap
// left ~1px of real air and the wall read as one dense block.
// ⚠ THE ROW GAP MUST CLEAR THE PRINT ROW. .pfs hangs BELOW its card (top:100%, margin-top:-14px),
// so the vertical gap is what keeps it off the next card. 64px was tuned when that row was one
// line at 35px; wrapping it to two lines takes 78px and needs 78-14=64 — exactly the gap, with
// nothing spare, which showed up as 5 rows intruding into the card below. 88px leaves real room.
'.grid{display:grid;gap:88px 30px;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));' +
// ⚠ THE ROW GAP MUST CLEAR THE PRINT ROW, and 88px was tuned for a row that NO LONGER HANGS
// THAT FAR. It was set when .pfs sat at top:100% with margin-top:-14px on the CARD — i.e. the
// whole row (up to 78px wrapped) hung below the card and the gap had to clear all of it.
// The row now straddles the BANNER's bottom edge and overhangs by --tape-overlap plus whatever
// grows downward, and the card's own 26px bottom padding absorbs part of that. Reported from a
// 390x844 phone shot: a ~180px empty band between one row's print stickers and the next row's
// header stickers — the gap is now clearing a distance nothing occupies.
// 56px still clears a two-line row (the ro/ru case) with room; the assertion that keeps this
// honest is `neighbour 0` in check-poster-card-fits, which fails if a row reaches another card.
'.grid{display:grid;gap:56px 30px;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));' +
'max-width:1180px;margin:0 auto}' +
// The per-row control. Sits above its own grid, right-aligned so it reads as a setting for the
// block below rather than as content, and inherits the wall's type so it does not announce
@ -513,8 +571,13 @@ useShadowStyles(
'border-radius:6px;background:#fff;color:inherit;cursor:pointer}' +
'.is-dark .perrow__label{color:#9fb3a6}' +
'.is-dark .perrow__select{background:#132019;border-color:rgba(110,231,183,.20)}' +
'.card{position:relative;background:#fff;border:1px solid #e3e6ea;border-radius:3px;' +
'padding:24px 12px 26px;box-shadow:0 1px 3px rgba(0,0,0,.10),0 6px 18px rgba(0,0,0,.07);' +
// --card-pad-top IS the banner's top edge in card coordinates, and .tape-h reads it to
// centre itself there. Declared as a var so the two cannot drift apart the way a second
// hand-typed 24px would.
'.card{--card-pad-top:24px;--tape-overlap:16px;position:relative;background:#fff;border:1px solid #e3e6ea;' +
'border-radius:3px;' +
'padding:var(--card-pad-top) 12px 26px;' +
'box-shadow:0 1px 3px rgba(0,0,0,.10),0 6px 18px rgba(0,0,0,.07);' +
'transition:transform .16s ease,box-shadow .16s ease}' +
// ★ THE BLUE OUTLINE IS A HOVER STATE, NOT A MISSING STATIC BORDER. Several reports have asked
// for "the blue outline around the poster" to be restored, and MEASURED 2026-09-24 there has
@ -546,7 +609,21 @@ useShadowStyles(
'font-size:10.5px;font-weight:600;letter-spacing:.02em;cursor:pointer;' +
'border:1px solid rgba(0,0,0,.08);background:#eef2f6;color:#23303c}' +
'.is-dark .tape{background:#1d2b23;color:#cfe9d9;border-color:rgba(255,255,255,.10)}' +
'.tape-h{top:-11px;width:82%;padding:4px 8px;border-radius:999px;' +
// ★ THE HEADER STICKER STRADDLES THE BANNER'S TOP EDGE, for the same reason the footer one
// straddles the bottom. MEASURED before: 35px above the banner's top edge, -1px below — it
// floated over the card, touching the banner nowhere. The Driver reads the rounded top
// sticker as the one that "looks beautiful and is really well spaced", so this keeps its
// size and shape exactly and only corrects WHERE it sits: centred on the edge it is taping.
// --banner-pad-top reserves the banner surface it lands on, so it never reaches the map.
// .tape-h is positioned against .card (it is a direct child), and the banner's top is the
// card's padding-top — hence top:24px, read from the card rather than restated.
// Same rule as .pfs below: the OVERLAP onto the banner is the constant, not the centre. This
// sticker is a single line at every width so centring happened to work, but pinning both
// stickers the same way means one --tape-overlap governs both and neither can drift.
// Its height grows UPWARD here (bottom-anchored to the band) so it never reaches the map.
'.tape-h{top:calc(var(--card-pad-top,24px) - var(--tape-overlap,16px));' +
'transform:translateX(-50%);' +
'width:82%;padding:4px 8px;border-radius:999px;' +
'display:flex;align-items:center;justify-content:center;gap:5px}' +
'.tape-h:hover{background:#e2e8ee}' +
'.hcount{font-weight:800;font-variant-numeric:tabular-nums}' +
@ -570,20 +647,43 @@ useShadowStyles(
// a vertical one the driver saw. Anchoring at top:100% pins it just under the card, so the
// second line grows DOWN into empty space. MEASURED: 92px between the preview's bottom and
// the next card, of which the single-line row used 35 — 51px spare.
// ★ THE FOOTER IS TAPE TOO (Driver): "see how the header for show on map is inline on the top of
// the poster box? but the footer print options are not inline on the border or outline of the
// box... we want the footer to imitate the header, by being inline like a piece of tape".
// The header does that with .tape-h{top:-11px}: a fixed offset that works because its height is
// fixed. The footer's height is NOT fixed -- it holds two to four controls plus a select, and it
// grows with translated labels -- so a fixed margin-top:-14px left it hanging BELOW the edge
// instead of sitting on it, and any height change moved it again.
// translate(-50%,-50%) at top:100% centres it on the bottom border at ANY height, which is the
// same "piece of tape" the header achieves and is self-correcting when a locale makes it taller.
// That also answers the rest of the Driver's rule -- "if the box is too tall to centre we just
// let it take up an overlap of the poster box, but not so far that it overlaps the poster blue
// outline": half the tape overlaps the card and half hangs below, so the overlap grows only half
// as fast as the tape does and never reaches the preview.
'.pfs{position:absolute;top:100%;left:50%;transform:translate(-50%,-50%);' +
// ★ THE FOOTER STICKER STRADDLES THE BANNER'S BOTTOM EDGE (Driver): "see how the header for
// show on map is inline on the top of the poster box? but the footer print options are not
// inline on the border or outline of the box... we want the footer to imitate the header, by
// being inline like a piece of tape".
//
// top:100% + translate(-50%,-50%) centres it on that edge at ANY height, which matters because
// the row's height is NOT fixed: it holds two to four controls and grows with translated
// labels (MEASURED 68px in en/ro, 54px in zh). A fixed margin-top left it hanging below the
// edge and moved again whenever the height changed. Centring is self-correcting, so no
// constant here has to track the locale.
//
// ⚠ top:100% IS THE BANNER'S BOTTOM EDGE ONLY BECAUSE .pfs LIVES INSIDE .main. As a SIBLING
// of the banner it resolved against the CARD instead — MEASURED card bottom 453 vs banner
// bottom 426, so the row centred on a line 27px past the banner, touching it nowhere. The
// re-parenting and the centring are one fix; neither works alone.
//
// Half the sticker lands on the banner, so the banner must reserve white space for it or the
// sticker sits on the map: that is --banner-pad-bottom on .main, and the gate asserts the
// result (map-covered 0) rather than trusting the arithmetic.
//
// ⚠ DO NOT ADD margin-top HERE. `margin:0` in the shorthand below RESETS any margin-top
// declared above it — a shorthand always beats an earlier longhand in the same block. A
// margin-top:-1px sat here computing to 0px while I believed it was doing the positioning;
// a mutation test (set it to 26px, expect the row to move) proved it inert.
// ⚠ NOT CENTRED — and this is a MEASURED correction, not a preference. translate(-50%,-50%)
// puts HALF the row's height onto the banner, and the row's height is CONTENT-DEPENDENT: it
// wraps to two lines in ro/ru and to three at 390px once the labels wrap. Measured ro/390px:
// 27 of 27 stickers overlapping the map by 4px, while en and zh were clean at the same
// padding. No fixed --banner-pad-bottom can be right for every locale x width, because half
// of a variable is a variable.
//
// So the OVERLAP is the constant instead of the centre: the sticker's top is pinned a fixed
// --tape-overlap above the banner's bottom edge, and any extra height grows DOWNWARD into
// the grid's row gap, where there is room. The amount sitting on the banner is now the same
// in all 11 locales at every width, which is what --banner-pad-bottom has to clear.
'.pfs{position:absolute;top:calc(100% - var(--tape-overlap,16px));' +
'left:50%;transform:translateX(-50%);' +
// ⚠ min-width:0 ON THE FIELDSET ITSELF. A <fieldset> gets `min-width:min-content` from the
// UA stylesheet, which OVERRIDES width and max-width — measured: computed width 431px
// inside a 212px card while the rule said 82%. That is the real reason `width:82%` read as
@ -599,13 +699,30 @@ useShadowStyles(
// of 108 controls clipped. Fitting a row by hiding what its buttons say is not fitting it.
// flex-basis:auto + wrap lets each control take the width its LABEL needs and flow to a
// second line, which there is room for now that the row grows downward.
'.pfs > .pbtn{flex:0 1 auto;min-width:0;white-space:nowrap}' +
// ⚠ THE <select> IS THE ONE THAT WILL NOT SHRINK. Measured: with min-width:0 applied to
// every child, .pstock still rendered 188px inside a 198px row because a <select> sizes to
// its WIDEST OPTION and `flex:0 1 auto` keeps that as its basis. flex-basis:0 makes it share
// the row like the buttons do; a cap keeps it from dominating the three text controls.
'.pfs > .pstock{flex:0 1 auto;min-width:0;max-width:100%}' +
// ⚠ nowrap HERE IS A CLIPPING BUG IN THE NARROW x LONG-LABEL CORNER, and it only appeared once
// the per-row default became 2: at 390px that halves the card to ~170px, and Romanian's
// "Pachet de cartonașe" (133px) plus two more controls cannot share it on one line.
// MEASURED after the merge: 54 of 81 controls clipped at ro/390px, en and zh clean — long
// labels and a narrow card, exactly the pair each change was individually fine with.
// The standing ruling on this wall is that a label too long for its box WRAPS; nowrap forced
// the browser to clip instead. Wrapping costs a second line in the one corner that needs it.
'.pfs > .pbtn{flex:0 1 auto;min-width:0;white-space:normal;overflow-wrap:break-word;' +
'text-align:center;line-height:1.2}' +
'.is-dark .pfs{background:#132019;border-color:rgba(110,231,183,.24)}' +
// ── THE REVEALED SIZE ROW ────────────────────────────────────────────────────
// Sits directly under the print row, still hanging into the grid's 88px row-gap.
// ⚠ THE OFFSET IS MEASURED, NOT GUESSED. Both rows share .main as containing block, so
// "under .pfs" means top:100% + .pfs's own height — and that height is NOT a constant:
// the row wraps to a second line in long-label locales (ro/ru measured at 2 lines).
// A hard-coded 20px would tuck the size row under the print row in exactly those
// locales. toggleDeck() reads the live offsetHeight and writes it to --pstocks-gap.
// Hangs below the footer sticker, which is itself half-overhanging the banner edge — so the
// offset starts at HALF the print row's height, not all of it.
'.pstocks{top:100%;margin-top:calc(var(--pstocks-gap,20px) / 2);width:92%;max-width:92%;' +
'box-sizing:border-box;padding:3px;border-radius:2px;background:#fff;' +
'display:flex;flex-wrap:wrap;gap:3px;justify-content:center;' +
'box-shadow:0 1px 4px rgba(0,0,0,.10);cursor:default}' +
'.is-dark .pstocks{background:#132019;border-color:rgba(110,231,183,.24)}' +
'.pfs > legend{margin:0 auto;padding:0 5px;font-size:8px;text-transform:uppercase;' +
'letter-spacing:.6px;font-weight:700;color:#8a93a0}' +
'.is-dark .pfs > legend{color:#7f9c8c}' +
@ -616,13 +733,27 @@ useShadowStyles(
'border-radius:2px;background:transparent;transition:background .15s}' +
'.pbtn:hover{color:#10b981}' +
'.pbtn:hover::after{background:#10b981}' +
// Stock picker (cardStocks). Sized a shade smaller than .pbtn so it reads as a
// SETTING for the neighbouring Card Deck button rather than a fourth action, and
// `flex:0 1 auto` keeps it from taking an equal share of the row the way .pbtn does.
'.pstock{flex:0 1 auto;max-width:100%;border:1px solid #d8dde3;border-radius:3px;' +
// The deck button is a DISCLOSURE, so it says so while open — a caret that flips,
// plus the green the row already uses for "active", so the reader can see which
// card's sizes are showing without reading aria-expanded.
'.pbtn-deck[aria-expanded="true"]{color:#10b981}' +
'.pbtn-deck::before{content:"\\25B8";display:inline-block;margin-right:3px;font-size:8px;' +
'transition:transform .15s}' +
'.pbtn-deck[aria-expanded="true"]::before{transform:rotate(90deg)}' +
// Size choices. These are BUTTONS, not <option>s — the Driver ruled out a native
// dropdown here because it collides with the other dropdowns on the host page.
// Each one both picks the size and prints, so it is styled as an action.
// ⚠ NO nowrap HERE, and this is a MEASURED correction rather than a preference. With
// white-space:nowrap the two landscape labels rendered 161px inside a 169px row and CLIPPED
// ("Card 3.5 x 2.5 in (landscape)", "Postcard 6 x 4 in (landscape)"). The standing ruling on
// this wall is that a label too long for its box WRAPS — never an ellipsis, never a crop. So
// each size takes a full row of its own when it needs one; flex-wrap on .pstocks gives it the
// line, and min-width:0 lets the text actually break instead of forcing the box open.
'.pstock{flex:0 1 auto;min-width:0;max-width:100%;border:1px solid #d8dde3;border-radius:3px;' +
'background:#fff;color:#5b6672;font:inherit;font-size:9.5px;font-weight:600;' +
'padding:2px 4px;cursor:pointer;line-height:1.25}' +
'padding:2px 5px;cursor:pointer;line-height:1.25;white-space:normal;overflow-wrap:break-word}' +
'.pstock:hover{border-color:#10b981;color:#10b981}' +
'.pstock.is-on{border-color:#10b981;color:#0b7a5b;background:rgba(16,185,129,.10)}' +
// previewAction='print' only. The zoom-in cursor says "this opens something", and
// the focus-visible outline is not optional: it is how a keyboard user sees where
// they are on a control that is otherwise just a picture.
@ -665,6 +796,31 @@ useShadowStyles(
'box-shadow:0 6px 10px -8px rgba(0,0,0,.35);border-radius:4px 4px 0 0}' +
'.is-dark .sec-head{border-bottom-color:rgba(255,255,255,.12);background:#0a1410;' +
'box-shadow:0 6px 10px -8px rgba(0,0,0,.7)}' +
// ★ THE TRANSPARENT STRIP ABOVE A PINNED HEADER (Driver 2026-09-25): "there is a transparent
// section above the header, so when you're scrolling the posters are visible above the header
// ... in the white space between the header and the top of the page."
//
// CAUSE, MEASURED at 1440px: `position:sticky; top:0` pins to the scroll container's PADDING
// BOX, not its border box. .wall has padding-top:22px, so a pinned header comes to rest 22px
// down and the cards keep scrolling through those 22px. Probed at scrollTop 400/900/1600 the
// element painting in that strip was IMG.img every time — literally posters flying past above
// the header, exactly as reported.
//
// WHY A PSEUDO-ELEMENT AND NOT `top:-22px`. Pulling the header up by the padding does pin it
// flush, but sticky clips nothing: the header's own first 22px would then sit outside the
// container and its text would be cut off. And padding-top on the header itself would move the
// text down instead of covering anything. A ::before that extends the SAME opaque fill upward
// covers the strip while leaving both layout and the header's own position untouched — the
// `outline` vs `border` distinction again: paint outside the box, do not re-lay-out.
//
// Height comes from --wall-pad-top (set on .wall), never a second literal 22px.
// Full-bleed on x: the header is inset 2px by its own margin and the strip has to cover the
// container's 18px side padding too, or the posters show through at the edges.
'.sec-head::before{content:"";position:absolute;left:-20px;right:-20px;' +
'bottom:100%;height:var(--wall-pad-top,0px);background:inherit;pointer-events:none}' +
// Nothing to cover when the wall is not the scroller (--wall-pad-top is 0px there), but be
// explicit: an embedded wall must never paint a bar over its host.
'.wall--embedded .sec-head::before{display:none}' +
// A collapsed row is a plain bar — no bottom rule reaching for content that is
// not there.
'.sec-head.is-shut{border-bottom-color:transparent;margin-bottom:6px}' +
@ -684,7 +840,51 @@ useShadowStyles(
'.sec-count{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}' +
// Poster aspect is 3ft x 3ft SQUARE — the real print ratio, so the wall previews
// the actual artefact rather than an arbitrary box.
'.main{position:relative;aspect-ratio:1/1;border:1px solid #dfe3e7;overflow:hidden;' +
// ★ THE BANNER IS NOT THE MAP — and until now it effectively was.
// Driver 2026-09-25, the sticker/olive-oil-bottle brief: this box is a BANNER that DISPLAYS
// the map, the way a label displays a design. A banner taped to a wall gets a sticker at the
// top and one at the bottom, each straddling its edge — "slight overlap over the banner, but
// not over the content of the banner... if I put a piece of tape over the text it would be
// offensive to the map."
//
// MEASURED before this change, and it is the whole problem: banner 240-426, map 241-425.
// whiteSpaceTopOfBanner 1px, whiteSpaceBottomOfBanner 1px. The map filled the banner edge to
// edge, so there was NO banner surface for a sticker to sit on — the bottom sticker could
// only ever hang off into space (measured straddle: 1px on, 67px off) or cover the map.
// Both wrong, and no amount of moving the sticker fixes it; the BANNER had to grow.
//
// --banner-pad is that room. .img is object-fit:contain, which fits the CONTENT box, so
// padding reserves banner surface and the map shrinks to sit inside it rather than spilling.
// The bottom gets MORE than the top because the footer sticker is taller than the header one
// and, per the brief, should overhang the page BELOW the banner more than it covers the
// banner itself ("probably an equal or more overlap").
//
// ⚠ aspect-ratio applies to the BORDER box here (box-sizing is border-box in this root), so
// the square is the BANNER's shape, not the map's. The map inside is correspondingly shorter
// than it is wide, which is correct: the banner is the thing being framed.
//
// overflow IS VISIBLE, DELIBERATELY. .pfs lives INSIDE this banner so its top:100% resolves
// here; under `hidden` the print row was clipped to nothing. Nothing else needs the clip:
// .img is object-fit:contain (never paints past its box) and .ph is a smaller flow box.
// ⚠ DERIVED FROM THE STICKER HEIGHTS, NOT CHOSEN — and derived with SLACK, because the
// heights are fractional. A sticker centred on the banner edge puts HALF its height onto the
// banner, so the reserved white space must exceed that half.
// attempt 1: 10px/26px by eye vs 34px/68px stickers -> map covered by 6px and 8px.
// attempt 2: exactly half + 4px (21px/38px) -> STILL 1px of overlap on 27 cards in en and
// ro but NOT zh, where the print row is 54px rather than 68px. The rects are
// fractional (68.x), so half of them rounds past an integer constant; and the
// row's height is LOCALE-DEPENDENT, which no fixed number can track.
// Hence generous slack rather than a tighter constant: 12px past the widest measured half.
// The gate asserts the relationship (map-covered 0), so if a future locale wraps the row to
// three lines this fails loudly instead of quietly shaving the map.
// Now that the OVERLAP is a constant (--tape-overlap on .card) rather than half a
// content-dependent height, the banner only has to reserve that overlap plus daylight —
// the same number in every locale at every width. It was 29/50 chasing half of a row that
// is 68px in en, 54px in zh and taller still when ro wraps at 390px; that chase is what put
// 4px of sticker on the map. 16 + 8 of daylight, both ends.
'.main{--banner-pad-top:24px;--banner-pad-bottom:24px;' +
'position:relative;aspect-ratio:1/1;border:1px solid #dfe3e7;overflow:visible;' +
'padding:var(--banner-pad-top) 10px var(--banner-pad-bottom);' +
'background:#fbfcfd;display:flex;align-items:center;justify-content:center}' +
'.is-dark .main{background:#0c1712;border-color:rgba(255,255,255,.08)}' +
// CONTAIN, not cover: `cover` crops to fill, which on a square sheet sliced off
@ -798,26 +998,44 @@ useShadowStyles(
the two beside it. -->
<div class="ph-note">preview unavailable</div>
</div>
</div>
<fieldset class="tape pfs">
<legend>{{ t('buttons.printOptions') }}</legend>
<button v-if="!(hidePrayerListWhenSoon && !canPrintRows)" type="button" class="pbtn"
@click="downloadPacket(item)">{{ t('buttons.prayerList') }}</button>
<!-- CARD DECK — only when this wall can supply rows. A button that
cannot work is worse than an absent one, so it is gated on the
records provider rather than shown and then failing at click. -->
<button v-if="canPrintRows" type="button" class="pbtn"
@click="prints.printCardDeck(item)">{{ t('buttons.cardDeck') }}</button>
<!-- Stock picker, only where the wall offers one AND the deck is printable.
@click.stop so choosing a size does not also fire the card's own
show-on-map handler. Labels come from card-render.js STOCK. -->
<select v-if="canPrintRows && stockOptions.length" class="pstock"
v-model="pickedStock" :aria-label="t('buttons.cardStockAria')" @click.stop>
<option v-for="s in stockOptions" :key="s.id" :value="s.id">{{ s.label }}</option>
</select>
<button type="button" class="pbtn" @click="exportPoster(item)">{{ t('buttons.posterMap') }}</button>
</fieldset>
<!-- ★ THE PRINT ROW LIVES INSIDE .main, NOT BESIDE IT (Driver 2026-09-25).
It is `position:absolute; top:100%`, which resolves against the nearest
positioned ANCESTOR. As a SIBLING of .main that ancestor was .card, whose
bottom sits 26px past the frame — measured: frame bottom 426, row top 452.
That 26px of nothing is why it read as a detached row instead of tape.
Moved INSIDE .main, top:100% is the frame's own bottom edge, so the row
starts exactly on the border it is taping over. -->
<fieldset class="tape pfs">
<legend>{{ t('buttons.printOptions') }}</legend>
<button v-if="!(hidePrayerListWhenSoon && !canPrintRows)" type="button" class="pbtn"
@click="downloadPacket(item)">{{ t('buttons.prayerList') }}</button>
<button type="button" class="pbtn" @click="exportPoster(item)">{{ t('buttons.posterMap') }}</button>
<!-- ★ CARD DECK IS NOT A PEER OPTION, AND ITS SIZE PICKER IS HIDDEN UNTIL ASKED
FOR (Driver 2026-09-25): "card should not be second option, and I don't want
to add the dropdown for the card because it makes it hard for others to have
dropdowns too... only let you click on the dropdown when you click on print
the cards, which will show you a hidden dropdown to let you choose the print
size option you want."
So the deck is a DISCLOSURE, not a sibling button: clicking it reveals this
card's own size row, and the size buttons there are what actually print.
A 162px <select> was the single widest control in a 172px row — removing it
from the default row is most of why the row now fits on one line. -->
<button v-if="canPrintRows" type="button" class="pbtn pbtn-deck"
:aria-expanded="String(deckOpenFor === item.id)"
@click.stop="toggleDeck(item.id, $event)">{{ t('buttons.cardDeck') }}</button>
</fieldset>
<!-- The revealed size row. Rendered as SIBLINGS in a second tape rather than a
<select> so there is no native dropdown to collide with other dropdowns —
which is the constraint the Driver gave, not a styling preference. -->
<div v-if="canPrintRows && stockOptions.length && deckOpenFor === item.id"
class="tape pstocks" role="group" :aria-label="t('buttons.cardStockAria')">
<button v-for="s in stockOptions" :key="s.id" type="button" class="pstock"
:class="{ 'is-on': pickedStock === s.id }"
@click.stop="printDeckAt(item, s.id)">{{ s.label }}</button>
</div>
</div>
</div>
</div>
</section>

View file

@ -76,23 +76,74 @@ export function usePosterPrints(opts = {}) {
// ── 1. THE POSTER ─────────────────────────────────────────────────────────
// Prefers the pre-rendered image; falls back to rasterising the poster's own
// HTML in the browser, which is what lets the repo stop shipping 41 MB of webp.
// ★ THIS IS THE WALL'S posterAction='print' PATH, PROMOTED. It was a private function in
// PosterWall.vue, which meant the MAP LEGEND's own "print this list's poster" had no way to
// reach it — and the legend had grown a fourth implementation instead: a hand-rolled
// `<a download>` of the .webp. That was wrong twice over. It silently downloads a file,
// which the Driver ruled out explicitly (2026-09-23: "no silent file download as the default
// action"), and it has NO fallback, so on the 27 lists whose .webp does not ship it simply
// saved a 404.
//
// Two guards below were each learned the hard way and MUST survive any future move:
// 1. a THROWN fetch is a MISS, not an error (blocked/offline/refused reject rather than
// returning !ok). Letting it escape skips the rasterise fallback entirely — measured
// during the wall migration, when check-poster-print-fallback went PASS -> FAIL with 0
// .html requests.
// 2. a blank raster must fail LOUDLY. An SVG inside an <img> is an isolated document, so a
// missing stylesheet paints every fill black with no error at all.
//
// `assetsBase` is the seam: the wall and the map profile resolve their own base, and this
// stays ignorant of where a bundle serves its posters from.
async function printPoster(item) {
if (busy()) return;
const kind = 'Poster';
const base = opts.assetsBase ? opts.assetsBase() : null;
const id = item?.id ?? item;
set(item, kind, 'fetch', 'fetching the poster', 25);
try {
const url = opts.posterUrl ? opts.posterUrl(item) : null;
let html = '';
if (url) {
const res = await fetch(url, { cache: 'force-cache' });
if (res.ok) html = await res.text();
}
if (!html) throw new Error('no poster available for this item');
openPrintFrame(html, { autoprint: true });
done(item, kind);
// An explicit posterUrl still wins — it is how a caller points at a poster that is not
// <base>/posters/<id>.webp at all.
const direct = opts.posterUrl ? opts.posterUrl(item) : null;
let res = null;
const webp = direct || (base ? `${base}/posters/${id}.webp` : null);
if (webp) { try { res = await fetch(webp); } catch { /* a miss, see guard 1 */ } }
if (res && res.ok) { await deliverPoster(await res.blob(), item, kind); return; }
if (!base) throw new Error('no poster available for this item');
// NO PRE-RENDERED IMAGE? GENERATE IT. The .webp posters are ~41MB across a tree and are
// not shipped by default; the HTML is the SOURCE and is smaller than the image it makes.
set(item, kind, 'render', 'generating the poster', 45);
const htmlRes = await fetch(`${base}/posters/${id}.html`);
if (!htmlRes.ok) throw new Error(`poster not found (${htmlRes.status})`);
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js');
const blob = await rasterPoster(await htmlRes.text(), { scale: 2 });
if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank');
await deliverPoster(blob, item, kind);
} catch (e) { fail(item, kind, e); }
}
// A 3ft SQUARE on a PORTRAIT sheet must fit by its SHORTER axis: scaling to the full page
// WIDTH makes it taller than the printable height and it overflows onto a second page.
// Constrain both axes and let object-fit letterbox it.
const posterObjectUrls = [];
async function deliverPoster(blob, item, kind) {
set(item, kind, 'save', 'opening the print dialog', 80);
const url = URL.createObjectURL(blob);
posterObjectUrls.push(url);
const title = `${opts.downloadPrefix || 'doxa'} — ${label(item)}`;
openPrintFrame(
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + esc(title) + '</title>'
+ '<style>@page{size:letter portrait;margin:12mm}'
+ 'html,body{margin:0;height:100%;background:#fff}'
+ 'body{display:flex;align-items:center;justify-content:center}'
+ 'img{max-width:100%;max-height:100vh;object-fit:contain;display:block}'
+ '@media print{body{height:auto}'
+ 'img{width:auto;max-width:100%;max-height:100%;object-fit:contain}}</style></head>'
+ `<body><img src="${url}" alt="${esc(label(item))}"></body></html>`,
{ autoprint: true },
);
done(item, kind);
}
// ── 2. THE LIST ───────────────────────────────────────────────────────────
// The Driver's FIRST option — "that's what the whole app was about": the list
// itself as a print-paginated table with a prayer checkbox per row.

View file

@ -93,34 +93,6 @@ export function detectLocale() {
/**
* Build a fresh i18n instance. Called once per <doxa-map> element.
*/
/**
* Read a catalogue string WITHOUT the vue-i18n plugin.
*
* WHY THIS EXISTS. Some components are mounted as their OWN custom element by a bundle entry that
* calls defineCustomElement(Component) and installs nothing — people-groups-grid is one. useI18n()
* inside such a component THROWS, and a thrown setup() removes the subtree silently (this repo has
* already paid for that once: three buttons vanished behind ignorable console noise). So a shared
* component that must work in BOTH mounts needs a plugin-free reader, which is all this is.
*
* Falls back locale -> en -> undefined, so a caller can keep its own literal as the last resort and
* a missing translation degrades to English rather than to a key.
*
* @param {string} path dotted, e.g. 'grid.hide_filters'
* @param {string} [locale] defaults to the detected one (?lang= / host / browser)
*/
export function tStatic(path, locale) {
const tryTree = (loc) => {
let node = messages[loc]
for (const part of String(path).split('.')) {
if (!node || typeof node !== 'object') return undefined
node = node[part]
}
return typeof node === 'string' ? node : undefined
}
const want = locale || detectLocale()
return tryTree(want) ?? tryTree('en')
}
export function createAppI18n() {
const locale = detectLocale()
// ⚠ REFLECT AN RTL LOCALE ON THE DOCUMENT. Only one component reads RTL_LOCALES
@ -149,3 +121,31 @@ export function createAppI18n() {
}
export default createAppI18n
/**
* Read a catalogue string WITHOUT the vue-i18n plugin.
*
* WHY THIS EXISTS. Some components are mounted as their OWN custom element by a bundle entry that
* calls defineCustomElement(Component) and installs nothing — people-groups-grid is one. useI18n()
* inside such a component THROWS, and a thrown setup() removes the subtree silently (this repo has
* already paid for that once: three buttons vanished behind ignorable console noise). So a shared
* component that must work in BOTH mounts needs a plugin-free reader, which is all this is.
*
* Falls back locale -> en -> undefined, so a caller can keep its own literal as the last resort and
* a missing translation degrades to English rather than to a key.
*
* @param {string} path dotted, e.g. 'grid.hide_filters'
* @param {string} [locale] defaults to the detected one (?lang= / host / browser)
*/
export function tStatic(path, locale) {
const tryTree = (loc) => {
let node = messages[loc]
for (const part of String(path).split('.')) {
if (!node || typeof node !== 'object') return undefined
node = node[part]
}
return typeof node === 'string' ? node : undefined
}
const want = locale || detectLocale()
return tryTree(want) ?? tryTree('en')
}

View file

@ -0,0 +1,13 @@
{
"title": "كل مجموعات الشعوب",
"locations": "{n} موقعًا (مجموعة × بلد)",
"doxa": "مجموعات شعوب DOXA",
"frontier": "حدودية — لم يبدأ شيء",
"unreached": "غير مُبشَّرة — العمل يبدأ",
"reached": "مُبشَّرة — الكنيسة موجودة",
"hint": "انقر على صف لعرضها كلها · انقر مرة أخرى لعرض الكل",
"loading": "جارٍ تحميل كل مجموعات الشعوب…",
"loadError": "تعذّر تحميل الخريطة.",
"pray": "صلِّ لأجلهم ←",
"population": "السكان {n}"
}

View file

@ -0,0 +1,25 @@
{
"title": "الوصول إلى الإنترنت حيث تعيش الشعوب غير المنخرطة",
"bands": {
"yes": "متصل 60%+",
"partial": "جزئي 25–59%",
"no": "منخفض، أقل من 25%",
"unknown": "غير مُقاس"
},
"peopleGroup": "شعب",
"caveat": "أرقام وطنية (البنك الدولي). بيانات الاتصال لكل مدينة غير متاحة للعامة — لا تستنتج حال مدينة من بيانات بلدها.",
"sheet": {
"close": "إغلاق",
"ai": "عُثر على هذه البيانات عبر بحث معمّق بالذكاء الاصطناعي — وقد تتضمن معلومات خاطئة.",
"dismiss": "إغلاق التنبيه",
"internetUsers": "مستخدمو الإنترنت",
"measured": "سنة القياس",
"mobilePer100": "اشتراكات الجوال لكل 100",
"peopleGroups": "الشعوب",
"topPlatform": "المنصة الأولى",
"unknown": "غير معروف",
"carriers": "شركات الاتصالات ومزودو الإنترنت ({n})",
"socialPlatforms": "منصات التواصل الاجتماعي",
"note": "أرقام وطنية وليست قياسات للمدن. أسماء شركات الاتصالات هي أسماء أصحاب سجلات ASN — وتشمل البنوك والجامعات إلى جانب مزودي الإنترنت، لذا فإن تصنيفها كشركات اتصالات تقدير مبني على الاسم وليس تصنيفًا مُتحقَّقًا منه."
}
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "أدوات البحث",
"clustering": "التجميع",
"filters": "عوامل التصفية",
"pin_dim": "خفوت النقاط",
"export_poster": "تصدير ملصق…",
"reload_data": "إعادة تحميل البيانات",
"off": "إيقاف",
"native": "أصلي",
"mst": "الشجرة الممتدة",
"network": "شبكة",
"off_desc": "إظهار كل نقطة على حدة",
"native_desc": "تجميع دوائر Mapbox (الأسرع)",
"mst_desc": "شبكات تقارب بالشجرة الممتدة الصغرى",
"network_desc": "خطوط ربط بأقرب جار",
"search_languages": "ابحث عن اللغات…",
"search_religions": "ابحث عن الأديان…",
"reset_all": "إعادة تعيين الكل",
"no_languages": "لم تُحمَّل أي لغات بعد",
"no_religions": "لم تُحمَّل أي أديان بعد",
"unadopted": "غير متبنّى",
"min": "الأدنى",
"max": "الأعلى",
"adopted": "متبنَّاة",
"adoption": "التبني",
"engaged": "مُخْدُومَة",
"engagement": "المشاركة",
"unengaged": "يحتاج إلى انخراط",
"language": "اللغة",
"religion": "الدين",
"population": "السكان"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "كتلة التقارب",
"gospel_access": "الوصول إلى الإنجيل",
"people": "الشعوب",
"people_name": "الشعب (الاسم)",
"praying": "يصلّون",
"religion_family": "عائلة الأديان",
"search_language": "ابحث عن لغة",
"search_people": "ابحث عن شعب",
"search_place": "ابحث عن مكان",
"search_religion": "ابحث عن دين",
"upgs_in_scope": "الشعوب ضمن النطاق",
"countries_touched": "الدول المشمولة",
"unreached": "غير موصولين",
"sum_population": "مجموع السكان",
"sum_praying": "مجموع المصلّين",
"charts": "الرسوم البيانية",
"table": "الجدول",
"cluster": "المجموعة",
"country": "الدولة",
"engagement": "المشاركة",
"language": "اللغة",
"language_family": "عائلة اللغة",
"people_group": "الشعب",
"population": "السكان",
"prayer": "الصلاة",
"region": "المنطقة",
"religion": "الدين",
"status": "الحالة",
"adopted": "متبنَّاة",
"filters": "عوامل التصفية",
"gospel_resources": "موارد الإنجيل",
"map": "الخريطة",
"search_placeholder": "ابحث عن الشعوب والأماكن واللغات والأديان…"
}

View file

@ -98,5 +98,30 @@
"wrongLanguage": "لغة غير صحيحة",
"varietyNotStated": "(اللهجة غير محددة)",
"inLanguage": "بـ"
},
"guide": {
"dotVsCountry": "كل نقطة هي شعب واحد من شعوب دوكسا ولغته؛ أما لون البلد فهو عدد تلك الشعوب.",
"noneFoundShort": "«لا شيء» تعني غير موجود في المصادر التي بحثنا فيها — وليست دليلًا على عدم وجوده.",
"tapCountry": "اضغط على بلد لترى الموارد الفعلية، شعبًا شعبًا."
},
"tables": {
"title": "موارد الإنجيل — البيانات",
"showing": "عرض",
"of": "من",
"for": "لـ",
"filteredTo": "مصفّى حسب",
"showAll": "عرض كل الموارد",
"t_doxa": "دوكسا — الوصول إلى الإنجيل",
"t_links": "كل مورد ورابطه",
"t_nothing": "حيث لا يوجد شيء",
"t_countrygap": "الفجوة حسب البلد",
"t_translation": "يسمعون ولا نص لديهم",
"t_translationcountry": "أين نترجم",
"t_gaps": "الفجوات والفرص",
"t_bridges": "جسور المدن",
"t_deaf": "فجوة الصم",
"t_resources": "الموارد حسب الشعب",
"t_languages": "اللغات حسب البلد",
"t_growth": "الوصول مقابل نمو السكان"
}
}

View file

@ -0,0 +1,19 @@
{
"search": "ابحث عن الشعوب…",
"hide_filters": "إخفاء عوامل التصفية",
"show_filters": "إظهار عوامل التصفية",
"total": "الإجمالي",
"showing": "المعروض",
"load_more": "عرض المزيد",
"full_profile": "الملف الكامل",
"wagf_region": "المنطقة",
"wagf_block": "كتلة التقارب",
"rop1": "الشعب",
"religion": "الدين",
"exact_filter": "تطابق تام",
"clear_all": "مسح الكل",
"loading": "جارٍ التحميل…",
"no_results": "لا توجد شعوب مطابقة لهذا التصفية.",
"type_to_search": "اكتب للبحث...",
"load_failed": "تعذّر تحميل بيانات الشعوب. هذه مشكلة اتصال، وليست مشكلة في التصفية."
}

View file

@ -2,5 +2,8 @@
"menuComingSoon": "القائمة قادمة قريباً",
"pinHint": {
"tapDot": "اضغط على نقطة ملوّنة — لا يزال هناك من يحتاج إلى الصلاة"
}
},
"upg100WallHeading": "قائمتي UPG 100 — قوائم الصلاة والملصقات",
"upg100WallTag": "تعبئة للصلاة",
"upg100WallIntro": "وزّع مهمة الصلاة في مجموعات من خمسين ومئة — كما أطعم يسوع الخمسة آلاف (لوقا ٩:١٤). كل ملصق هو قائمة من 100 يمكنك أن تصلي بها وتطبعها وتعلّقها على الحائط — مصلّيًا لأجلهم <strong>باسم شعبهم</strong>."
}

View file

@ -19,5 +19,9 @@
"country": "الدولة",
"upg100Region": "المنطقة",
"upg100Zone": "قوائم UPG-100",
"upg100Group": "مجموعة شعبية"
}
"upg100Group": "مجموعة شعبية",
"selectView": "اختر العرض",
"views": "العروض",
"prayerResources": "موارد الصلاة",
"map": "الخريطة"
}

View file

@ -0,0 +1,21 @@
{
"kicker": "DOXA · الرؤية",
"title": "كل شعب. كل مكان.",
"tagline": "من كل مجموعات الشعوب في العالم إلى التي تصلي لأجلها DOXA، محسوبةً من البيانات.",
"funnelLabel": "من كل مجموعات الشعوب إلى مجموعات شعوب DOXA",
"allLabel": "مجموعات الشعوب",
"allNote": "كل مجموعة شعب، في كل بلد تعيش فيه",
"unreachedLabel": "غير المُبشَّرة",
"unreachedNote": "أقل من 2% إنجيليون",
"frontierLabel": "الحدودية",
"frontierNote": "أقل من 0.1% إنجيليون",
"doxaLabel": "مجموعات شعوب DOXA",
"doxaNote": "{frontier} منها حدودية",
"people": "{n} نسمة",
"sources": "مجموعات الشعوب: {imb}، بيانات {date}. قائمة DOXA: ‏pray.doxa.life، {date2}.",
"howCounted": "كيف تُحسب هذه الأرقام",
"ruleAll": "كل صف في بيانات IMB لمجموعات الشعوب: مجموعة شعب واحدة في بلد واحد.",
"ruleUnreached": "أولوية الضياع لدى IMB من 0 إلى 2: أقل من 2% إنجيليون.",
"ruleFrontier": "أولوية الضياع لدى IMB رقم 0: أقل من 0.1% إنجيليون. جزء من غير المُبشَّرة.",
"ruleDoxa": "كل مجموعة في قائمة صلاة DOXA، مطابَقة مع بيانات IMB حسب الشعب والبلد. ليست كلها حدودية."
}

View file

@ -0,0 +1,13 @@
{
"title": "Alle Volksgruppen",
"locations": "{n} Standorte (Gruppe × Land)",
"doxa": "DOXA-Volksgruppen",
"frontier": "Frontier — noch nichts begonnen",
"unreached": "Unerreicht — Arbeit beginnt",
"reached": "Erreicht — Gemeinde ist da",
"hint": "Zeile anklicken, um alle zu sehen · erneut klicken für alle",
"loading": "Alle Volksgruppen werden geladen…",
"loadError": "Die Karte konnte nicht geladen werden.",
"pray": "Für sie beten →",
"population": "Einw. {n}"
}

View file

@ -0,0 +1,25 @@
{
"title": "Internetzugang dort, wo Volksgruppen ohne Engagement leben",
"bands": {
"yes": "verbunden 60%+",
"partial": "teilweise 25–59%",
"no": "gering, unter 25%",
"unknown": "nicht gemessen"
},
"peopleGroup": "Volksgruppe",
"caveat": "LANDESWEITE Zahlen (Weltbank). Konnektivität je Stadt ist nicht öffentlich verfügbar — schließen Sie nicht vom Land auf eine Stadt.",
"sheet": {
"close": "Schließen",
"ai": "Diese Daten wurden durch KI-gestützte Recherche gefunden — sie können falsche Angaben enthalten.",
"dismiss": "Ausblenden",
"internetUsers": "Internetnutzer",
"measured": "Gemessen",
"mobilePer100": "Mobilfunk pro 100",
"peopleGroups": "Volksgruppen",
"topPlatform": "Wichtigste Plattform",
"unknown": "unbekannt",
"carriers": "Netzbetreiber & ISPs ({n})",
"socialPlatforms": "Soziale Plattformen",
"note": "Landesweite Zahlen, keine Messungen je Stadt. Betreibernamen sind ASN-Registerinhaber — darunter neben ISPs auch Banken und Universitäten; die Einstufung als Netzbetreiber ist daher eine Namensheuristik, keine geprüfte Klassifizierung."
}
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Forschungssteuerung",
"clustering": "Clustering",
"filters": "Filter",
"pin_dim": "Pin-Transparenz",
"export_poster": "Poster exportieren…",
"reload_data": "Daten neu laden",
"off": "Aus",
"native": "Nativ",
"mst": "MST",
"network": "Netzwerk",
"off_desc": "Jeden Pin einzeln anzeigen",
"native_desc": "Mapbox-Kreiscluster (am schnellsten)",
"mst_desc": "Affinitätsnetze über minimalen Spannbaum",
"network_desc": "Verbindungslinien zum nächsten Nachbarn",
"search_languages": "Sprachen suchen…",
"search_religions": "Religionen suchen…",
"reset_all": "Alles zurücksetzen",
"no_languages": "Noch keine Sprachen geladen",
"no_religions": "Noch keine Religionen geladen",
"unadopted": "Nicht adoptiert",
"min": "min",
"max": "max",
"adopted": "Adoptiert",
"adoption": "Adoption",
"engaged": "Engagiert",
"engagement": "Engagement",
"unengaged": "Braucht Engagement",
"language": "Sprache",
"religion": "Religion",
"population": "Bevölkerung"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "Affinitätsblock",
"gospel_access": "Zugang zum Evangelium",
"people": "Völker",
"people_name": "Volk (Name)",
"praying": "Betend",
"religion_family": "Religionsfamilie",
"search_language": "Sprache suchen",
"search_people": "Volk suchen",
"search_place": "Ort suchen",
"search_religion": "Religion suchen",
"upgs_in_scope": "UPGs im Bereich",
"countries_touched": "berührte Länder",
"unreached": "unerreicht",
"sum_population": "Σ Bevölkerung",
"sum_praying": "Σ Betende",
"charts": "Diagramme",
"table": "Tabelle",
"cluster": "Cluster",
"country": "Land",
"engagement": "Engagement",
"language": "Sprache",
"language_family": "Sprachfamilie",
"people_group": "Volk",
"population": "Bevölkerung",
"prayer": "Gebet",
"region": "Region",
"religion": "Religion",
"status": "Status",
"adopted": "Adoptiert",
"filters": "Filter",
"gospel_resources": "Evangeliumsressourcen",
"map": "Karte",
"search_placeholder": "Volksgruppen, Orte, Sprachen, Religionen suchen…"
}

View file

@ -98,5 +98,30 @@
"wrongLanguage": "falsche Sprache",
"varietyNotStated": "(Varietät nicht angegeben)",
"inLanguage": "auf"
},
"guide": {
"dotVsCountry": "Ein Punkt ist EINE Volksgruppe von Doxa mit ihrer eigenen Sprache; ein Land ist die ANZAHL dieser Gruppen.",
"noneFoundShort": "„Nichts gefunden“ heißt: nicht in den geprüften Quellen — nie ein Beweis, dass es nichts gibt.",
"tapCountry": "Tippen Sie auf ein Land, um die Ressourcen Gruppe für Gruppe zu sehen."
},
"tables": {
"title": "Evangeliums-Ressourcen — Daten",
"showing": "Zeige",
"of": "von",
"for": "für",
"filteredTo": "Gefiltert nach",
"showAll": "Alle Ressourcen zeigen",
"t_doxa": "Doxa — Zugang zum Evangelium",
"t_links": "Jede Ressource + Link",
"t_nothing": "Wo nichts gefunden wurde",
"t_countrygap": "Die Lücke nach Land",
"t_translation": "Hören ja, Text nein",
"t_translationcountry": "Wo übersetzen",
"t_gaps": "Lücken & Chancen",
"t_bridges": "Stadt-Brücken",
"t_deaf": "Die Gehörlosen-Lücke",
"t_resources": "Ressourcen nach Volksgruppe",
"t_languages": "Sprachen nach Land",
"t_growth": "Zugang vs. Bevölkerungswachstum"
}
}

View file

@ -0,0 +1,19 @@
{
"search": "Volksgruppen suchen…",
"hide_filters": "Filter ausblenden",
"show_filters": "Filter anzeigen",
"total": "Gesamt",
"showing": "Angezeigt",
"load_more": "Mehr laden",
"full_profile": "Vollständiges Profil",
"wagf_region": "Region",
"wagf_block": "Affinitätsblock",
"rop1": "Volk",
"religion": "Religion",
"exact_filter": "Exakt",
"clear_all": "Alle löschen",
"loading": "Wird geladen…",
"no_results": "Keine Volksgruppen entsprechen diesem Filter.",
"type_to_search": "Zum Suchen tippen...",
"load_failed": "Die Daten konnten nicht geladen werden. Das ist ein Verbindungsproblem, nicht Ihr Filter."
}

View file

@ -2,5 +2,8 @@
"menuComingSoon": "Menü folgt in Kürze",
"pinHint": {
"tapDot": "Tippe auf einen farbigen Punkt — jemand braucht noch Gebet"
}
},
"upg100WallHeading": "Meine UPG-100-Liste — Gebetslisten und Poster",
"upg100WallTag": "Gebetsmobilisierung",
"upg100WallIntro": "Teilen Sie die Gebetsaufgabe in Gruppen zu fünfzig und hundert auf — so wie Jesus die 5.000 speiste (Lukas 9,14). Jedes Poster ist eine 100er-Liste, die Sie beten, drucken und an die Wand hängen können — und für sie <strong>mit dem Namen ihrer Volksgruppe</strong> beten."
}

View file

@ -19,5 +19,9 @@
"country": "Land",
"upg100Region": "Region",
"upg100Zone": "UPG-100-Listen",
"upg100Group": "Volksgruppe"
}
"upg100Group": "Volksgruppe",
"selectView": "Ansicht wählen",
"views": "Ansichten",
"prayerResources": "Gebetsressourcen",
"map": "Karte"
}

View file

@ -0,0 +1,21 @@
{
"kicker": "DOXA · Vision",
"title": "Jedes Volk. Jeder Ort.",
"tagline": "Von allen Volksgruppen der Welt bis zu denen, für die DOXA betet, aus den Daten gezählt.",
"funnelLabel": "Von allen Volksgruppen zu den DOXA-Volksgruppen",
"allLabel": "Volksgruppen",
"allNote": "jede Volksgruppe in jedem Land, in dem sie lebt",
"unreachedLabel": "Unerreicht",
"unreachedNote": "unter 2 % Evangelikale",
"frontierLabel": "Frontier",
"frontierNote": "unter 0,1 % Evangelikale",
"doxaLabel": "DOXA-Volksgruppen",
"doxaNote": "{frontier} davon sind Frontier",
"people": "{n} Menschen",
"sources": "Volksgruppen: {imb}, Datenstand {date}. DOXA-Liste: pray.doxa.life, {date2}.",
"howCounted": "So wird gezählt",
"ruleAll": "Jede Zeile der IMB-Volksgruppendaten: eine Volksgruppe in einem Land.",
"ruleUnreached": "IMB-Lostness-Priorität 0 bis 2: weniger als 2 % Evangelikale.",
"ruleFrontier": "IMB-Lostness-Priorität 0: weniger als 0,1 % Evangelikale. Eine Teilmenge der Unerreichten.",
"ruleDoxa": "Jede Gruppe auf der DOXA-Gebetsliste, den IMB-Daten nach Volk und Land zugeordnet. Nicht alle sind Frontier."
}

View file

@ -0,0 +1,13 @@
{
"title": "All People Groups",
"locations": "{n} group–country locations",
"doxa": "DOXA people groups",
"frontier": "Frontier — nothing started",
"unreached": "Unreached — work beginning",
"reached": "Reached — church is there",
"hint": "Click a row to see all of them · click again for everything",
"loading": "Loading every people group…",
"loadError": "The map could not be loaded.",
"pray": "Pray for them →",
"population": "pop {n}"
}

View file

@ -0,0 +1,25 @@
{
"title": "Internet access where unengaged people groups live",
"bands": {
"yes": "connected 60%+",
"partial": "partial 25–59%",
"no": "low, under 25%",
"unknown": "not measured"
},
"peopleGroup": "people group",
"caveat": "NATIONAL figures (World Bank). Per-city connectivity is not publicly available — do not read a city off a country.",
"sheet": {
"close": "Close",
"ai": "Data was found by AI deep research — it is subject to false information.",
"dismiss": "Dismiss",
"internetUsers": "Internet users",
"measured": "Measured",
"mobilePer100": "Mobile per 100",
"peopleGroups": "People groups",
"topPlatform": "Top platform",
"unknown": "unknown",
"carriers": "Carriers & ISPs ({n})",
"socialPlatforms": "Social platforms",
"note": "National figures, not city measurements. Carrier names are ASN registry holders — that set includes banks and universities as well as ISPs, so the carrier flag is a name heuristic, not a verified classification."
}
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Research controls",
"clustering": "Clustering",
"filters": "Filters",
"pin_dim": "Pin dim",
"export_poster": "Export poster…",
"reload_data": "Reload data",
"off": "Off",
"native": "Native",
"mst": "MST",
"network": "Network",
"off_desc": "Show every pin individually",
"native_desc": "Mapbox circle clusters (fastest)",
"mst_desc": "Minimum-spanning-tree affinity webs",
"network_desc": "Nearest-neighbor connection lines",
"search_languages": "Search languages…",
"search_religions": "Search religions…",
"reset_all": "Reset all",
"no_languages": "No languages loaded yet",
"no_religions": "No religions loaded yet",
"unadopted": "Unadopted",
"min": "min",
"max": "max",
"adopted": "Adopted",
"adoption": "Adoption",
"engaged": "Engaged",
"engagement": "Engagement",
"unengaged": "Unengaged",
"language": "Language",
"religion": "Religion",
"population": "Population"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "Bloc",
"gospel_access": "Gospel Access",
"people": "People",
"people_name": "People (name)",
"praying": "Praying",
"religion_family": "Religion Family",
"search_language": "Search language",
"search_people": "Search people",
"search_place": "Search place",
"search_religion": "Search religion",
"upgs_in_scope": "UPGs in scope",
"countries_touched": "countries touched",
"unreached": "unreached",
"sum_population": "Σ population",
"sum_praying": "Σ praying",
"charts": "Charts",
"table": "Table",
"cluster": "Cluster",
"country": "Country",
"engagement": "Engagement",
"language": "Language",
"language_family": "Language Family",
"people_group": "People Group",
"population": "Population",
"prayer": "Prayer",
"region": "Region",
"religion": "Religion",
"status": "Status",
"adopted": "Adopted",
"filters": "Filters",
"gospel_resources": "Gospel Resources",
"map": "Map",
"search_placeholder": "Search people groups, place, language, religion…"
}

View file

@ -98,5 +98,30 @@
"wrongLanguage": "wrong language",
"varietyNotStated": "(variety not stated)",
"inLanguage": "in"
},
"guide": {
"dotVsCountry": "A dot is ONE of Doxa's people groups and its own language; a country is a COUNT of those groups.",
"noneFoundShort": "“None found” means not present in the pools this build checked — never proof that nothing exists.",
"tapCountry": "Tap a country to see the actual resources, group by group."
},
"tables": {
"title": "Gospel Resources — Data",
"showing": "Showing",
"of": "of",
"for": "for",
"filteredTo": "Filtered to",
"showAll": "Show all resources",
"t_doxa": "Doxa — Gospel Access",
"t_links": "Every Resource + Link",
"t_nothing": "Where Nothing Was Found",
"t_countrygap": "The Gap by Country",
"t_translation": "Can Hear It, No Text",
"t_translationcountry": "Where to Translate",
"t_gaps": "Gaps & Opportunities",
"t_bridges": "City Bridges",
"t_deaf": "The Deaf Gap",
"t_resources": "Resources by People Group",
"t_languages": "Languages by Country",
"t_growth": "Access vs Population Growth"
}
}

View file

@ -0,0 +1,19 @@
{
"search": "Search people groups…",
"hide_filters": "Hide Filters",
"show_filters": "Show Filters",
"total": "Total",
"showing": "Showing",
"load_more": "Load more",
"full_profile": "Full profile",
"wagf_region": "Region",
"wagf_block": "Affinity Block",
"rop1": "People Group",
"religion": "Religion",
"exact_filter": "Exact",
"clear_all": "Clear All",
"loading": "Loading…",
"no_results": "No people groups match this filter.",
"type_to_search": "Type to search...",
"load_failed": "People-group data could not be loaded. This is a connection problem, not your filter."
}

View file

@ -2,5 +2,8 @@
"menuComingSoon": "Menu coming soon",
"pinHint": {
"tapDot": "Tap a coloured dot — someone still needs prayer"
}
},
"upg100WallHeading": "My UPG 100 List — Prayer Lists & Posters",
"upg100WallTag": "Prayer Mobilization",
"upg100WallIntro": "Hand away the prayer task in clusters of fifties and hundreds — the way Jesus fed the 5,000 (Luke 9:14). Each poster is a 100-list you can pray, print, and put on a wall — praying for them <strong>by their people group name</strong>."
}

View file

@ -19,5 +19,9 @@
"country": "Country",
"upg100Region": "Region",
"upg100Zone": "UPG-100 Lists",
"upg100Group": "People Group"
}
"upg100Group": "People Group",
"selectView": "Select View",
"views": "Views",
"prayerResources": "Prayer Resources",
"map": "Map"
}

View file

@ -0,0 +1,21 @@
{
"kicker": "DOXA · Vision",
"title": "Every people. Every place.",
"tagline": "From every people group on earth to the ones DOXA prays for, counted from the data.",
"funnelLabel": "From all people groups to DOXA people groups",
"allLabel": "People groups",
"allNote": "each people group, in each country it lives in",
"unreachedLabel": "Unreached",
"unreachedNote": "under 2% evangelical",
"frontierLabel": "Frontier",
"frontierNote": "under 0.1% evangelical",
"doxaLabel": "DOXA people groups",
"doxaNote": "{frontier} of them are frontier",
"people": "{n} people",
"sources": "People groups: {imb}, data of {date}. DOXA list: pray.doxa.life, {date2}.",
"howCounted": "How these are counted",
"ruleAll": "Every row of the IMB people-group data: one people group in one country.",
"ruleUnreached": "IMB lostness priority 0 to 2: less than 2% evangelical.",
"ruleFrontier": "IMB lostness priority 0: less than 0.1% evangelical. A subset of the unreached.",
"ruleDoxa": "Every group on the DOXA prayer list, matched to the IMB data by people and country. Not all are frontier."
}

View file

@ -0,0 +1,13 @@
{
"title": "Todos los grupos étnicos",
"locations": "{n} ubicaciones (grupo × país)",
"doxa": "Grupos étnicos de DOXA",
"frontier": "Frontera — nada iniciado",
"unreached": "No alcanzados — el trabajo comienza",
"reached": "Alcanzados — la iglesia está ahí",
"hint": "Haga clic en una fila para verlos todos · de nuevo para ver todo",
"loading": "Cargando todos los grupos étnicos…",
"loadError": "No se pudo cargar el mapa.",
"pray": "Ore por ellos →",
"population": "pobl. {n}"
}

View file

@ -0,0 +1,25 @@
{
"title": "Acceso a internet donde viven pueblos sin implicación",
"bands": {
"yes": "conectado 60%+",
"partial": "parcial 25–59%",
"no": "bajo, menos del 25%",
"unknown": "sin medir"
},
"peopleGroup": "pueblo",
"caveat": "Cifras NACIONALES (Banco Mundial). La conectividad por ciudad no está disponible públicamente: no deduzca una ciudad a partir de su país.",
"sheet": {
"close": "Cerrar",
"ai": "Estos datos se obtuvieron mediante investigación con IA y pueden contener información falsa.",
"dismiss": "Descartar",
"internetUsers": "Usuarios de internet",
"measured": "Medido",
"mobilePer100": "Móviles por cada 100",
"peopleGroups": "Pueblos",
"topPlatform": "Plataforma principal",
"unknown": "desconocido",
"carriers": "Operadores y proveedores de internet ({n})",
"socialPlatforms": "Redes sociales",
"note": "Cifras nacionales, no mediciones por ciudad. Los nombres de operadores son titulares de registros ASN (incluyen bancos y universidades además de proveedores), así que la marca de operador es una heurística por nombre, no una clasificación verificada."
}
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Controles de investigación",
"clustering": "Agrupación",
"filters": "Filtros",
"pin_dim": "Atenuar puntos",
"export_poster": "Exportar póster…",
"reload_data": "Recargar datos",
"off": "Desactivado",
"native": "Nativo",
"mst": "MST",
"network": "Red",
"off_desc": "Mostrar cada punto por separado",
"native_desc": "Grupos circulares de Mapbox (lo más rápido)",
"mst_desc": "Redes de afinidad por árbol de expansión mínima",
"network_desc": "Líneas de conexión al vecino más cercano",
"search_languages": "Buscar idiomas…",
"search_religions": "Buscar religiones…",
"reset_all": "Restablecer todo",
"no_languages": "Aún no se han cargado idiomas",
"no_religions": "Aún no se han cargado religiones",
"unadopted": "No adoptado",
"min": "mín",
"max": "máx",
"adopted": "Adoptado",
"adoption": "Adopción",
"engaged": "Comprometido",
"engagement": "Compromiso",
"unengaged": "Necesita implicación",
"language": "Idioma",
"religion": "Religión",
"population": "Población"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "Bloque de afinidad",
"gospel_access": "Acceso al evangelio",
"people": "Pueblos",
"people_name": "Pueblo (nombre)",
"praying": "Orando",
"religion_family": "Familia religiosa",
"search_language": "Buscar idioma",
"search_people": "Buscar pueblo",
"search_place": "Buscar lugar",
"search_religion": "Buscar religión",
"upgs_in_scope": "UPG en el ámbito",
"countries_touched": "países alcanzados",
"unreached": "no alcanzados",
"sum_population": "Σ población",
"sum_praying": "Σ orando",
"charts": "Gráficos",
"table": "Tabla",
"cluster": "Clúster",
"country": "País",
"engagement": "Compromiso",
"language": "Idioma",
"language_family": "Familia lingüística",
"people_group": "Pueblo",
"population": "Población",
"prayer": "Oración",
"region": "Región",
"religion": "Religión",
"status": "Estado",
"adopted": "Adoptado",
"filters": "Filtros",
"gospel_resources": "Recursos del Evangelio",
"map": "Mapa",
"search_placeholder": "Buscar pueblos, lugares, idiomas, religiones…"
}

View file

@ -98,5 +98,30 @@
"wrongLanguage": "idioma incorrecto",
"varietyNotStated": "(variedad no indicada)",
"inLanguage": "en"
},
"guide": {
"dotVsCountry": "Cada punto es UN pueblo de Doxa y su propio idioma; un país es el NÚMERO de esos pueblos.",
"noneFoundShort": "«Nada encontrado» significa ausente de las fuentes consultadas, nunca prueba de que no exista.",
"tapCountry": "Toca un país para ver los recursos, pueblo por pueblo."
},
"tables": {
"title": "Recursos del evangelio — Datos",
"showing": "Mostrando",
"of": "de",
"for": "para",
"filteredTo": "Filtrado por",
"showAll": "Mostrar todos los recursos",
"t_doxa": "Doxa — Acceso al evangelio",
"t_links": "Cada recurso + enlace",
"t_nothing": "Donde no se encontró nada",
"t_countrygap": "La brecha por país",
"t_translation": "Pueden oírlo, sin texto",
"t_translationcountry": "Dónde traducir",
"t_gaps": "Brechas y oportunidades",
"t_bridges": "Puentes urbanos",
"t_deaf": "La brecha sorda",
"t_resources": "Recursos por pueblo",
"t_languages": "Idiomas por país",
"t_growth": "Acceso vs crecimiento"
}
}

View file

@ -0,0 +1,19 @@
{
"search": "Buscar pueblos…",
"hide_filters": "Ocultar filtros",
"show_filters": "Mostrar filtros",
"total": "Total",
"showing": "Mostrando",
"load_more": "Cargar más",
"full_profile": "Perfil completo",
"wagf_region": "Región",
"wagf_block": "Bloque de afinidad",
"rop1": "Pueblo",
"religion": "Religión",
"exact_filter": "Exacto",
"clear_all": "Borrar todo",
"loading": "Cargando…",
"no_results": "Ningún pueblo coincide con este filtro.",
"type_to_search": "Escribe para buscar...",
"load_failed": "No se pudieron cargar los datos de pueblos. Es un problema de conexión, no de tu filtro."
}

View file

@ -2,5 +2,8 @@
"menuComingSoon": "Menú próximamente",
"pinHint": {
"tapDot": "Toca un punto de color: alguien todavía necesita oración"
}
},
"upg100WallHeading": "Mi lista UPG 100 — Listas de oración y carteles",
"upg100WallTag": "Movilización de oración",
"upg100WallIntro": "Reparte la tarea de la oración en grupos de cincuenta y de cien, como Jesús alimentó a los 5.000 (Lucas 9:14). Cada cartel es una lista de 100 que puedes orar, imprimir y poner en la pared — orando por ellos <strong>por el nombre de su pueblo</strong>."
}

View file

@ -19,5 +19,9 @@
"country": "País",
"upg100Region": "Región",
"upg100Zone": "Listas UPG-100",
"upg100Group": "Grupo étnico"
}
"upg100Group": "Grupo étnico",
"selectView": "Seleccionar vista",
"views": "Vistas",
"prayerResources": "Recursos de oración",
"map": "Mapa"
}

View file

@ -0,0 +1,21 @@
{
"kicker": "DOXA · Visión",
"title": "Cada pueblo. Cada lugar.",
"tagline": "De todos los grupos étnicos del mundo a aquellos por los que DOXA ora, contados a partir de los datos.",
"funnelLabel": "De todos los grupos étnicos a los grupos étnicos de DOXA",
"allLabel": "Grupos étnicos",
"allNote": "cada grupo étnico, en cada país donde vive",
"unreachedLabel": "No alcanzados",
"unreachedNote": "menos del 2 % evangélicos",
"frontierLabel": "Frontera",
"frontierNote": "menos del 0,1 % evangélicos",
"doxaLabel": "Grupos étnicos de DOXA",
"doxaNote": "{frontier} de ellos son de frontera",
"people": "{n} personas",
"sources": "Grupos étnicos: {imb}, datos de {date}. Lista de DOXA: pray.doxa.life, {date2}.",
"howCounted": "Cómo se cuentan",
"ruleAll": "Cada fila de los datos de grupos étnicos del IMB: un grupo étnico en un país.",
"ruleUnreached": "Prioridad de perdición del IMB de 0 a 2: menos del 2 % evangélicos.",
"ruleFrontier": "Prioridad de perdición del IMB 0: menos del 0,1 % evangélicos. Un subconjunto de los no alcanzados.",
"ruleDoxa": "Cada grupo de la lista de oración de DOXA, vinculado a los datos del IMB por pueblo y país. No todos son de frontera."
}

View file

@ -0,0 +1,13 @@
{
"title": "Tous les groupes ethniques",
"locations": "{n} emplacements (groupe × pays)",
"doxa": "Groupes ethniques DOXA",
"frontier": "Frontière — rien n’a commencé",
"unreached": "Non atteints — le travail commence",
"reached": "Atteints — l’Église est là",
"hint": "Cliquez sur une ligne pour les voir tous · recliquez pour tout voir",
"loading": "Chargement de tous les groupes ethniques…",
"loadError": "La carte n’a pas pu être chargée.",
"pray": "Priez pour eux →",
"population": "pop. {n}"
}

View file

@ -0,0 +1,25 @@
{
"title": "Accès à Internet là où vivent les peuples sans engagement",
"bands": {
"yes": "connecté 60 %+",
"partial": "partiel 25–59 %",
"no": "faible, moins de 25 %",
"unknown": "non mesuré"
},
"peopleGroup": "peuple",
"caveat": "Chiffres NATIONAUX (Banque mondiale). La connectivité par ville n'est pas publique — ne déduisez pas une ville à partir de son pays.",
"sheet": {
"close": "Fermer",
"ai": "Ces données proviennent d'une recherche approfondie par IA — elles peuvent contenir des informations erronées.",
"dismiss": "Ignorer",
"internetUsers": "Internautes",
"measured": "Mesuré",
"mobilePer100": "Mobiles pour 100",
"peopleGroups": "Peuples",
"topPlatform": "Plateforme principale",
"unknown": "inconnu",
"carriers": "Opérateurs et FAI ({n})",
"socialPlatforms": "Réseaux sociaux",
"note": "Chiffres nationaux, pas des mesures par ville. Les noms d'opérateurs sont les titulaires de registres ASN (on y trouve aussi des banques et des universités à côté des FAI), donc l'étiquette « opérateur » est une heuristique fondée sur le nom, non une classification vérifiée."
}
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Commandes de recherche",
"clustering": "Regroupement",
"filters": "Filtres",
"pin_dim": "Atténuation des points",
"export_poster": "Exporter l’affiche…",
"reload_data": "Recharger les données",
"off": "Désactivé",
"native": "Natif",
"mst": "MST",
"network": "Réseau",
"off_desc": "Afficher chaque point séparément",
"native_desc": "Groupes circulaires Mapbox (le plus rapide)",
"mst_desc": "Réseaux d’affinité par arbre couvrant minimal",
"network_desc": "Lignes vers le plus proche voisin",
"search_languages": "Rechercher des langues…",
"search_religions": "Rechercher des religions…",
"reset_all": "Tout réinitialiser",
"no_languages": "Aucune langue chargée pour l’instant",
"no_religions": "Aucune religion chargée pour l’instant",
"unadopted": "Non adopté",
"min": "min",
"max": "max",
"adopted": "Adopté",
"adoption": "Adoption",
"engaged": "Engagé",
"engagement": "Engagement",
"unengaged": "Besoin d'engagement",
"language": "Langue",
"religion": "Religion",
"population": "Population"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "Bloc d’affinité",
"gospel_access": "Accès à l’Évangile",
"people": "Peuples",
"people_name": "Peuple (nom)",
"praying": "En prière",
"religion_family": "Famille religieuse",
"search_language": "Rechercher une langue",
"search_people": "Rechercher un peuple",
"search_place": "Rechercher un lieu",
"search_religion": "Rechercher une religion",
"upgs_in_scope": "UPG dans le périmètre",
"countries_touched": "pays touchés",
"unreached": "non atteints",
"sum_population": "Σ population",
"sum_praying": "Σ en prière",
"charts": "Graphiques",
"table": "Tableau",
"cluster": "Grappe",
"country": "Pays",
"engagement": "Engagement",
"language": "Langue",
"language_family": "Famille linguistique",
"people_group": "Peuple",
"population": "Population",
"prayer": "Prière",
"region": "Région",
"religion": "Religion",
"status": "Statut",
"adopted": "Adopté",
"filters": "Filtres",
"gospel_resources": "Ressources de l'Évangile",
"map": "Carte",
"search_placeholder": "Rechercher peuples, lieux, langues, religions…"
}

View file

@ -98,5 +98,30 @@
"wrongLanguage": "mauvaise langue",
"varietyNotStated": "(variété non précisée)",
"inLanguage": "en"
},
"guide": {
"dotVsCountry": "Un point est UN peuple de Doxa et sa propre langue ; un pays est le NOMBRE de ces peuples.",
"noneFoundShort": "« Rien trouvé » signifie absent des sources consultées, jamais la preuve que rien n'existe.",
"tapCountry": "Touchez un pays pour voir les ressources, peuple par peuple."
},
"tables": {
"title": "Ressources de l'Évangile — Données",
"showing": "Affichage de",
"of": "sur",
"for": "pour",
"filteredTo": "Filtré sur",
"showAll": "Afficher toutes les ressources",
"t_doxa": "Doxa — Accès à l'Évangile",
"t_links": "Chaque ressource + lien",
"t_nothing": "Où rien n'a été trouvé",
"t_countrygap": "L'écart par pays",
"t_translation": "Entendent, sans texte",
"t_translationcountry": "Où traduire",
"t_gaps": "Écarts et opportunités",
"t_bridges": "Ponts urbains",
"t_deaf": "L'écart sourd",
"t_resources": "Ressources par peuple",
"t_languages": "Langues par pays",
"t_growth": "Accès vs croissance"
}
}

View file

@ -0,0 +1,19 @@
{
"search": "Rechercher des peuples…",
"hide_filters": "Masquer les filtres",
"show_filters": "Afficher les filtres",
"total": "Total",
"showing": "Affichage",
"load_more": "Charger plus",
"full_profile": "Profil complet",
"wagf_region": "Région",
"wagf_block": "Bloc d'affinité",
"rop1": "Peuple",
"religion": "Religion",
"exact_filter": "Exact",
"clear_all": "Tout effacer",
"loading": "Chargement…",
"no_results": "Aucun peuple ne correspond à ce filtre.",
"type_to_search": "Tapez pour rechercher...",
"load_failed": "Les données n’ont pas pu être chargées. C’est un problème de connexion, pas votre filtre."
}

View file

@ -2,5 +2,8 @@
"menuComingSoon": "Menu bientôt disponible",
"pinHint": {
"tapDot": "Touchez un point coloré — quelqu’un a encore besoin de prière"
}
},
"upg100WallHeading": "Ma liste UPG 100 — Listes de prière et affiches",
"upg100WallTag": "Mobilisation pour la prière",
"upg100WallIntro": "Répartissez la tâche de la prière par groupes de cinquante et de cent — comme Jésus a nourri les 5 000 (Luc 9:14). Chaque affiche est une liste de 100 que vous pouvez prier, imprimer et afficher au mur — en priant pour eux <strong>par le nom de leur peuple</strong>."
}

View file

@ -19,5 +19,9 @@
"country": "Pays",
"upg100Region": "Région",
"upg100Zone": "Listes UPG-100",
"upg100Group": "Groupe de peuples"
}
"upg100Group": "Groupe de peuples",
"selectView": "Choisir la vue",
"views": "Vues",
"prayerResources": "Ressources de prière",
"map": "Carte"
}

View file

@ -0,0 +1,21 @@
{
"kicker": "DOXA · Vision",
"title": "Chaque peuple. Chaque lieu.",
"tagline": "De tous les groupes ethniques du monde à ceux pour qui DOXA prie, comptés à partir des données.",
"funnelLabel": "De tous les groupes ethniques aux groupes ethniques DOXA",
"allLabel": "Groupes ethniques",
"allNote": "chaque groupe ethnique, dans chaque pays où il vit",
"unreachedLabel": "Non atteints",
"unreachedNote": "moins de 2 % d’évangéliques",
"frontierLabel": "Frontière",
"frontierNote": "moins de 0,1 % d’évangéliques",
"doxaLabel": "Groupes ethniques DOXA",
"doxaNote": "dont {frontier} de frontière",
"people": "{n} personnes",
"sources": "Groupes ethniques : {imb}, données du {date}. Liste DOXA : pray.doxa.life, {date2}.",
"howCounted": "Comment ces chiffres sont comptés",
"ruleAll": "Chaque ligne des données IMB : un groupe ethnique dans un pays.",
"ruleUnreached": "Priorité de perdition IMB 0 à 2 : moins de 2 % d’évangéliques.",
"ruleFrontier": "Priorité de perdition IMB 0 : moins de 0,1 % d’évangéliques. Un sous-ensemble des non-atteints.",
"ruleDoxa": "Chaque groupe de la liste de prière DOXA, rapproché des données IMB par peuple et pays. Tous ne sont pas de frontière."
}

View file

@ -0,0 +1,13 @@
{
"title": "सभी जनसमूह",
"locations": "{n} स्थान (समूह × देश)",
"doxa": "DOXA जनसमूह",
"frontier": "सीमांत — कुछ शुरू नहीं हुआ",
"unreached": "अप्रचारित — काम शुरू हो रहा है",
"reached": "प्रचारित — कलीसिया मौजूद है",
"hint": "सभी देखने के लिए पंक्ति पर क्लिक करें · सब कुछ देखने के लिए फिर क्लिक करें",
"loading": "सभी जनसमूह लोड हो रहे हैं…",
"loadError": "मानचित्र लोड नहीं हो सका।",
"pray": "उनके लिए प्रार्थना करें →",
"population": "जनसंख्या {n}"
}

View file

@ -0,0 +1,25 @@
{
"title": "इंटरनेट पहुँच जहाँ बिना सहभागिता वाले जनसमूह रहते हैं",
"bands": {
"yes": "जुड़ा हुआ 60%+",
"partial": "आंशिक 25–59%",
"no": "कम, 25% से नीचे",
"unknown": "मापा नहीं गया"
},
"peopleGroup": "जनसमूह",
"caveat": "राष्ट्रीय आँकड़े (विश्व बैंक)। शहर-स्तर की कनेक्टिविटी सार्वजनिक रूप से उपलब्ध नहीं है — देश के आँकड़ों से किसी शहर का अनुमान न लगाएँ।",
"sheet": {
"close": "बंद करें",
"ai": "यह डेटा एआई गहन शोध से मिला है — इसमें गलत जानकारी हो सकती है।",
"dismiss": "हटाएँ",
"internetUsers": "इंटरनेट उपयोगकर्ता",
"measured": "मापा गया",
"mobilePer100": "प्रति 100 मोबाइल",
"peopleGroups": "जनसमूह",
"topPlatform": "शीर्ष प्लेटफ़ॉर्म",
"unknown": "अज्ञात",
"carriers": "कैरियर और आईएसपी ({n})",
"socialPlatforms": "सोशल प्लेटफ़ॉर्म",
"note": "ये राष्ट्रीय आँकड़े हैं, शहरों के माप नहीं। कैरियर के नाम ASN रजिस्ट्री धारकों के हैं — उनमें आईएसपी के साथ बैंक और विश्वविद्यालय भी शामिल हैं, इसलिए कैरियर का चिह्न नाम पर आधारित अनुमान है, सत्यापित वर्गीकरण नहीं।"
}
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "शोध नियंत्रण",
"clustering": "क्लस्टरिंग",
"filters": "फ़िल्टर",
"pin_dim": "पिन धुंधलापन",
"export_poster": "पोस्टर निर्यात करें…",
"reload_data": "डेटा फिर से लोड करें",
"off": "बंद",
"native": "नेटिव",
"mst": "MST",
"network": "नेटवर्क",
"off_desc": "हर पिन अलग-अलग दिखाएँ",
"native_desc": "Mapbox वृत्त क्लस्टर (सबसे तेज़)",
"mst_desc": "न्यूनतम स्पैनिंग ट्री आधारित जाल",
"network_desc": "निकटतम पड़ोसी को जोड़ने वाली रेखाएँ",
"search_languages": "भाषाएँ खोजें…",
"search_religions": "धर्म खोजें…",
"reset_all": "सब रीसेट करें",
"no_languages": "अभी कोई भाषा लोड नहीं हुई",
"no_religions": "अभी कोई धर्म लोड नहीं हुआ",
"unadopted": "अ-गोद लिया",
"min": "न्यून",
"max": "अधि",
"adopted": "गोद लिया गया",
"adoption": "गोद लेना",
"engaged": "संपर्कित",
"engagement": "सहभागिता",
"unengaged": "सहभागिता चाहिए",
"language": "भाषा",
"religion": "धर्म",
"population": "जनसंख्या"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "समानता समूह",
"gospel_access": "सुसमाचार पहुँच",
"people": "जनसमूह",
"people_name": "जनसमूह (नाम)",
"praying": "प्रार्थना कर रहे",
"religion_family": "धर्म परिवार",
"search_language": "भाषा खोजें",
"search_people": "जनसमूह खोजें",
"search_place": "स्थान खोजें",
"search_religion": "धर्म खोजें",
"upgs_in_scope": "दायरे में UPG",
"countries_touched": "छुए गए देश",
"unreached": "अनपहुँचे",
"sum_population": "Σ जनसंख्या",
"sum_praying": "Σ प्रार्थना",
"charts": "चार्ट",
"table": "तालिका",
"cluster": "क्लस्टर",
"country": "देश",
"engagement": "सहभागिता",
"language": "भाषा",
"language_family": "भाषा परिवार",
"people_group": "जनसमूह",
"population": "जनसंख्या",
"prayer": "प्रार्थना",
"region": "क्षेत्र",
"religion": "धर्म",
"status": "स्थिति",
"adopted": "गोद लिया गया",
"filters": "फ़िल्टर",
"gospel_resources": "सुसमाचार संसाधन",
"map": "नक्शा",
"search_placeholder": "जनसमूह, स्थान, भाषा, धर्म खोजें…"
}

View file

@ -98,5 +98,30 @@
"wrongLanguage": "ग़लत भाषा",
"varietyNotStated": "(उपभाषा नहीं बताई गई)",
"inLanguage": "में"
},
"guide": {
"dotVsCountry": "हर बिंदु दोक्सा का एक जनसमूह और उसकी अपनी भाषा है; देश का रंग उन जनसमूहों की संख्या है।",
"noneFoundShort": "“कुछ नहीं मिला” का अर्थ है जाँचे गए स्रोतों में नहीं — यह प्रमाण नहीं कि कुछ है ही नहीं।",
"tapCountry": "संसाधन देखने के लिए किसी देश पर टैप करें, जनसमूह दर जनसमूह।"
},
"tables": {
"title": "सुसमाचार संसाधन — डेटा",
"showing": "दिखा रहे हैं",
"of": "में से",
"for": "के लिए",
"filteredTo": "फ़िल्टर:",
"showAll": "सभी संसाधन दिखाएँ",
"t_doxa": "दोक्सा — सुसमाचार पहुँच",
"t_links": "हर संसाधन + लिंक",
"t_nothing": "जहाँ कुछ नहीं मिला",
"t_countrygap": "देश अनुसार कमी",
"t_translation": "सुन सकते हैं, पाठ नहीं",
"t_translationcountry": "अनुवाद कहाँ करें",
"t_gaps": "कमियाँ और अवसर",
"t_bridges": "नगर सेतु",
"t_deaf": "बधिर कमी",
"t_resources": "जनसमूह अनुसार संसाधन",
"t_languages": "देश अनुसार भाषाएँ",
"t_growth": "पहुँच बनाम जनसंख्या वृद्धि"
}
}

View file

@ -0,0 +1,19 @@
{
"search": "जनसमूह खोजें…",
"hide_filters": "फ़िल्टर छिपाएँ",
"show_filters": "फ़िल्टर दिखाएँ",
"total": "कुल",
"showing": "दिखा रहे हैं",
"load_more": "और देखें",
"full_profile": "पूरी प्रोफ़ाइल",
"wagf_region": "क्षेत्र",
"wagf_block": "समानता समूह",
"rop1": "जनसमूह",
"religion": "धर्म",
"exact_filter": "सटीक",
"clear_all": "सब हटाएँ",
"loading": "लोड हो रहा है…",
"no_results": "इस फ़िल्टर से कोई जनसमूह मेल नहीं खाता।",
"type_to_search": "खोजने के लिए लिखें...",
"load_failed": "जनसमूह डेटा लोड नहीं हो सका। यह कनेक्शन की समस्या है, आपके फ़िल्टर की नहीं।"
}

View file

@ -2,5 +2,8 @@
"menuComingSoon": "मेनू शीघ्र आ रहा है",
"pinHint": {
"tapDot": "किसी रंगीन बिंदु पर टैप करें — किसी को अभी भी प्रार्थना की आवश्यकता है"
}
},
"upg100WallHeading": "मेरी UPG 100 सूची — प्रार्थना सूचियाँ और पोस्टर",
"upg100WallTag": "प्रार्थना लामबंदी",
"upg100WallIntro": "प्रार्थना के काम को पचास-पचास और सौ-सौ के समूहों में बाँटें — जैसे यीशु ने 5,000 लोगों को खिलाया (लूका 9:14)। हर पोस्टर 100 की एक सूची है जिसके लिए आप प्रार्थना कर सकते हैं, छाप सकते हैं और दीवार पर लगा सकते हैं — उनके लिए <strong>उनके जनसमूह के नाम से</strong> प्रार्थना करते हुए।"
}

View file

@ -19,5 +19,9 @@
"country": "देश",
"upg100Region": "क्षेत्र",
"upg100Zone": "UPG-100 सूचियाँ",
"upg100Group": "जनसमूह"
}
"upg100Group": "जनसमूह",
"selectView": "दृश्य चुनें",
"views": "दृश्य",
"prayerResources": "प्रार्थना संसाधन",
"map": "मानचित्र"
}

View file

@ -0,0 +1,21 @@
{
"kicker": "DOXA · दर्शन",
"title": "हर जनसमूह। हर स्थान।",
"tagline": "दुनिया के हर जनसमूह से उन तक जिनके लिए DOXA प्रार्थना करता है, आँकड़ों से गिने गए।",
"funnelLabel": "सभी जनसमूहों से DOXA जनसमूहों तक",
"allLabel": "जनसमूह",
"allNote": "हर जनसमूह, हर उस देश में जहाँ वह रहता है",
"unreachedLabel": "अप्रचारित",
"unreachedNote": "2% से कम इवेंजेलिकल",
"frontierLabel": "सीमांत",
"frontierNote": "0.1% से कम इवेंजेलिकल",
"doxaLabel": "DOXA जनसमूह",
"doxaNote": "इनमें से {frontier} सीमांत हैं",
"people": "{n} लोग",
"sources": "जनसमूह: {imb}, {date} के आँकड़े। DOXA सूची: pray.doxa.life, {date2}।",
"howCounted": "ये कैसे गिने गए",
"ruleAll": "IMB जनसमूह आँकड़ों की हर पंक्ति: एक देश में एक जनसमूह।",
"ruleUnreached": "IMB लॉस्टनेस प्राथमिकता 0 से 2: 2% से कम इवेंजेलिकल।",
"ruleFrontier": "IMB लॉस्टनेस प्राथमिकता 0: 0.1% से कम इवेंजेलिकल। अप्रचारितों का एक भाग।",
"ruleDoxa": "DOXA प्रार्थना सूची का हर समूह, जनसमूह और देश के आधार पर IMB आँकड़ों से मिलाया गया। सभी सीमांत नहीं हैं।"
}

View file

@ -0,0 +1,13 @@
{
"title": "Tutti i gruppi etnici",
"locations": "{n} località (gruppo × paese)",
"doxa": "Gruppi etnici DOXA",
"frontier": "Frontiera — non è iniziato nulla",
"unreached": "Non raggiunti — il lavoro inizia",
"reached": "Raggiunti — la chiesa c’è",
"hint": "Fai clic su una riga per vederli tutti · di nuovo per vedere tutto",
"loading": "Caricamento di tutti i gruppi etnici…",
"loadError": "Impossibile caricare la mappa.",
"pray": "Prega per loro →",
"population": "pop. {n}"
}

View file

@ -0,0 +1,25 @@
{
"title": "Accesso a internet dove vivono i popoli senza impegno",
"bands": {
"yes": "connesso 60%+",
"partial": "parziale 25–59%",
"no": "basso, sotto il 25%",
"unknown": "non misurato"
},
"peopleGroup": "popolo",
"caveat": "Dati NAZIONALI (Banca Mondiale). La connettività per città non è disponibile pubblicamente: non dedurre una città dal suo paese.",
"sheet": {
"close": "Chiudi",
"ai": "Questi dati provengono da una ricerca approfondita con IA e possono contenere informazioni errate.",
"dismiss": "Ignora",
"internetUsers": "Utenti internet",
"measured": "Misurato",
"mobilePer100": "Cellulari ogni 100",
"peopleGroups": "Popoli",
"topPlatform": "Piattaforma principale",
"unknown": "sconosciuto",
"carriers": "Operatori e ISP ({n})",
"socialPlatforms": "Piattaforme social",
"note": "Dati nazionali, non misure per città. I nomi degli operatori sono titolari di registri ASN (includono banche e università oltre agli ISP), quindi l'etichetta di operatore è un'euristica basata sul nome, non una classificazione verificata."
}
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Controlli di ricerca",
"clustering": "Raggruppamento",
"filters": "Filtri",
"pin_dim": "Opacità dei punti",
"export_poster": "Esporta poster…",
"reload_data": "Ricarica i dati",
"off": "Disattivato",
"native": "Nativo",
"mst": "MST",
"network": "Rete",
"off_desc": "Mostra ogni punto singolarmente",
"native_desc": "Cluster circolari Mapbox (il più veloce)",
"mst_desc": "Reti di affinità ad albero ricoprente minimo",
"network_desc": "Linee verso il vicino più prossimo",
"search_languages": "Cerca lingue…",
"search_religions": "Cerca religioni…",
"reset_all": "Reimposta tutto",
"no_languages": "Nessuna lingua ancora caricata",
"no_religions": "Nessuna religione ancora caricata",
"unadopted": "Non adottato",
"min": "min",
"max": "max",
"adopted": "Adottato",
"adoption": "Adozione",
"engaged": "Coinvolto",
"engagement": "Impegno",
"unengaged": "Necessita impegno",
"language": "Lingua",
"religion": "Religione",
"population": "Popolazione"
}

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