Merge main into bundler-2/work

# Conflicts:
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/my-upg-100-list/my-upg-100-list.js
#	public/doxa-maps/my-upg-100-list/poster-wall/index.html
#	public/doxa-maps/my-upg-100-list/upg-100-list/index.html
#	public/doxa-maps/my-upg-100-list/upg-100-mfe/index.html
#	public/doxa-maps/regions-dashboard/region-dashboards/index.html
#	public/doxa-maps/regions-dashboard/regions-dashboard.js
#	public/doxa-maps/regions-dashboard/regions-list/index.html
#	public/doxa-maps/regions-dashboard/regions-mfe/index.html
This commit is contained in:
doxa-maps-88655c70 2026-09-24 04:05:36 +00:00
commit a68b7c2cc2
28 changed files with 2192 additions and 318 deletions

View file

@ -58,6 +58,24 @@ const lang = await page.evaluate(() => document.documentElement.lang || 'en')
// the bug it is, and no other assertion here would notice.
const RTL = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi'])
const dir = await page.evaluate(() => document.documentElement.dir || 'ltr')
// SCRIPT FONT. Some languages need a face the system stack does not carry — Hindi
// renders as tofu boxes in Georgia/Palatino/serif. When the packet declares one,
// it must actually LOAD; a declared-but-failed @font-face is silent, and the page
// then prints as empty rectangles with every other assertion still green.
//
// ⚠ This asserts the FONT LOADED, not that glyphs are correct. A general tofu
// detector was attempted and abandoned: comparing shaped width against a missing
// font false-positives on every system-covered script (Cyrillic, CJK, Arabic all
// legitimately measure the same in both stacks), and an ink-profile heuristic
// scored CORRECTLY-RENDERED Devanagari the same as tofu, because its baseline bar
// gives a uniform column profile. A detector that blocks working packets is worse
// than none. Read the pixels for that; this covers the mechanical half.
const fontStatus = await page.evaluate(async () => {
await document.fonts.ready
const faces = [...document.fonts]
return { declared: faces.length, loaded: faces.filter((f) => f.status === 'loaded').length,
names: faces.map((f) => `${f.family}:${f.status}`) }
})
// The catalog sits beside the generator that produced this packet, so derive it
// from the packet's own path rather than hardcoding a machine-specific location.
const catalogPath = process.env.PACKET_STRINGS
@ -145,6 +163,10 @@ if (r.rhythms !== r.rhythmsTotal) fails.push(`only ${r.rhythms}/${r.rhythmsTotal
if (!r.hasMark5) fails.push('Mark 5:1-20 is NOT printed in full (Driver requirement for point 4)')
if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`)
if (r.mapFaults.length) fails.push(`map: ${r.mapFaults.join('; ')}`)
if (fontStatus.declared && fontStatus.loaded !== fontStatus.declared) {
fails.push(`a declared @font-face did not load (${fontStatus.names.join(', ')}) — `
+ 'text in that script will print as empty boxes')
}
if (RTL.has(lang) && dir !== 'rtl') fails.push(`lang="${lang}" is right-to-left but dir="${dir}"`)
if (!RTL.has(lang) && dir === 'rtl') fails.push(`lang="${lang}" is not RTL but dir="rtl"`)
if (translated === 0) {
@ -162,6 +184,7 @@ console.log(` Mark 5 in full : ${r.hasMark5}`)
console.log(` metacamp.org : ${r.metacamp}`)
console.log(` map : ${r.mapFaults.length ? r.mapFaults.join('; ') : 'palette resolves, not black'}`)
console.log(` page size : ${boxes[0] ? boxes[0].join(' x ') : '?'} pt`)
console.log(` script font : ${fontStatus.declared ? fontStatus.names.join(', ') : 'system stack'}`)
console.log(` language : ${lang} (dir=${dir}) (${r.rhythms}/${r.rhythmsTotal} rhythms in ${lang}`
+ `${translated === null ? '' : `, ${translated} strings translated`})`)

View file

@ -0,0 +1,95 @@
// check-print-geometry — the THREE print paths must each produce their OWN page
// geometry, from the UPG-100 page, in JavaScript.
//
// Driver: "make sure poster generation works and the aspect ratio of the print
// dialog can be custom based on your print point, so print dialog works for
// poster, for vertical packet, and for horizontal cards... not using python but
// all working in the jamstack maps."
//
// One gate over all three because the failure they share is the same: a print
// path silently falling back to letter. Chromium does that SILENTLY for any
// @page size it does not recognise ('A6 portrait' -> 612x792pt, no error), so
// every assertion here is on the MEASURED MediaBox, never on the stylesheet.
//
// Usage: node internal/check-print-geometry.mjs
// PACKET_ORIGIN (default http://127.0.0.1:4898) — a static server over the repo root
import { chromium } from 'playwright'
const ORIGIN = process.env.PACKET_ORIGIN || 'http://127.0.0.1:4898'
const API = process.env.PACKET_API || 'https://pray.doxa.life'
const ISO3 = process.env.PACKET_ISO3 || 'LAO'
// Each path, its expected page in POINTS (72pt = 1in), and its orientation.
const PATHS = [
{ id: 'packet (vertical)', w: 612, h: 792, orient: 'portrait' },
{ id: 'cards (poker)', w: 180, h: 252, orient: 'portrait', stock: 'poker' },
{ id: 'cards (landscape)', w: 252, h: 180, orient: 'landscape', stock: 'pokerLandscape' },
{ id: 'poster', w: null, h: null, orient: 'portrait' }, // raster, not paginated
]
const b = await chromium.launch()
const pg = await b.newPage({ viewport: { width: 1200, height: 900 } })
const errs = []; pg.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
await pg.goto(ORIGIN + '/')
const fails = []
const line = (id, got, want) => console.log(` ${id.padEnd(20)} ${got}${want ? ` (want ${want})` : ''}`)
// ── 1 + 2: the paginated paths, measured through page.pdf ────────────────────
for (const p of PATHS.filter((x) => x.w)) {
const html = await pg.evaluate(async ({ ORIGIN, API, ISO3, stock }) => {
const norm = await import(`${ORIGIN}/plugin-designs/my-upg-100-list/data/normalize.js`)
const strings = await fetch(`${ORIGIN}/library/scripts/posters/packet-strings.json`).then((r) => r.json())
const recs = norm.normalizeRecords(norm.extractRecords(await (await fetch(
`${API}/api/people-groups/list?fields=slug,name,country_code,population,religion,image_url,primary_language&lang=en`)).json()))
const groups = recs.filter((r) => String(r.country_code) === ISO3).slice(0, 6)
if (stock) {
const { createCardRenderer } = await import(`${ORIGIN}/library/packet/card-render.js`)
return createCardRenderer({ strings, lang: 'en', stock }).buildDeck({ id: 'x', label: 'X' }, groups)
}
const { createPacketRenderer } = await import(`${ORIGIN}/library/packet/packet-render.js`)
return createPacketRenderer({ strings, lang: 'en' }).buildPacket({ id: 'x', label: 'X' }, groups, new Map(), '')
}, { ORIGIN, API, ISO3, stock: p.stock })
await pg.setContent(html, { waitUntil: 'load' })
await pg.waitForTimeout(1200)
const pdf = await pg.pdf({ preferCSSPageSize: true })
const raw = pdf.toString('latin1')
const box = [...raw.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map((m) => [+m[1], +m[2]])[0] || [0, 0]
const [w, h] = box.map((v) => Math.round(v))
line(p.id, `${w} x ${h} pt`, `${p.w} x ${p.h}`)
if (Math.abs(w - p.w) > 2 || Math.abs(h - p.h) > 2) {
fails.push(`${p.id}: page is ${w}x${h}pt, expected ${p.w}x${p.h}pt`
+ (Math.abs(w - 612) < 2 && Math.abs(h - 792) < 2 && p.w !== 612
? ' — this is LETTER: the @page size silently fell back' : ''))
}
const landscape = w > h
if ((p.orient === 'landscape') !== landscape) {
fails.push(`${p.id}: orientation is ${landscape ? 'landscape' : 'portrait'}, expected ${p.orient}`)
}
}
// ── 3: the poster, which rasterises rather than paginating ───────────────────
const poster = await pg.evaluate(async ({ ORIGIN }) => {
const { rasterPoster, looksRendered } = await import(`${ORIGIN}/library/packet/poster-raster.js`)
const res = await fetch(`${ORIGIN}/library/scripts/posters/out/posters/laos.html`)
if (!res.ok) return { error: `poster HTML ${res.status}` }
const blob = await rasterPoster(await res.text(), { scale: 1 })
const bmp = await createImageBitmap(blob)
return { w: bmp.width, h: bmp.height, ok: await looksRendered(blob), bytes: blob.size }
}, { ORIGIN })
if (poster.error) fails.push(`poster: ${poster.error}`)
else {
line('poster', `${poster.w} x ${poster.h} px`, 'portrait, painted')
if (poster.w >= poster.h) fails.push(`poster rasterised ${poster.w}x${poster.h} — expected portrait`)
if (!poster.ok) fails.push('poster rasterised blank/black')
}
await b.close()
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
if (fails.length) {
console.error(`\nPRINT-GEOMETRY: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log('\nPRINT-GEOMETRY: PASS — every print path produces its own page geometry.')

970
library/data/countries.json Normal file
View file

@ -0,0 +1,970 @@
[
{
"value": "AFG",
"label": "Afghanistan"
},
{
"value": "ALB",
"label": "Albania"
},
{
"value": "DZA",
"label": "Algeria"
},
{
"value": "ASM",
"label": "American Samoa"
},
{
"value": "AND",
"label": "Andorra"
},
{
"value": "AGO",
"label": "Angola"
},
{
"value": "AIA",
"label": "Anguilla"
},
{
"value": "ATG",
"label": "Antigua and Barbuda"
},
{
"value": "ARG",
"label": "Argentina"
},
{
"value": "ARM",
"label": "Armenia"
},
{
"value": "ABW",
"label": "Aruba"
},
{
"value": "AUS",
"label": "Australia"
},
{
"value": "AUT",
"label": "Austria"
},
{
"value": "AZE",
"label": "Azerbaijan"
},
{
"value": "BHR",
"label": "Bahrain"
},
{
"value": "BGD",
"label": "Bangladesh"
},
{
"value": "BRB",
"label": "Barbados"
},
{
"value": "BLR",
"label": "Belarus"
},
{
"value": "BEL",
"label": "Belgium"
},
{
"value": "BLZ",
"label": "Belize"
},
{
"value": "BEN",
"label": "Benin"
},
{
"value": "BMU",
"label": "Bermuda"
},
{
"value": "BTN",
"label": "Bhutan"
},
{
"value": "BOL",
"label": "Bolivia"
},
{
"value": "BES",
"label": "Bonaire, Sint Eustatius, and Saba"
},
{
"value": "BIH",
"label": "Bosnia and Herzegovina"
},
{
"value": "BWA",
"label": "Botswana"
},
{
"value": "BRA",
"label": "Brazil"
},
{
"value": "BRN",
"label": "Brunei"
},
{
"value": "BGR",
"label": "Bulgaria"
},
{
"value": "BFA",
"label": "Burkina Faso"
},
{
"value": "BDI",
"label": "Burundi"
},
{
"value": "CPV",
"label": "Cabo Verde"
},
{
"value": "KHM",
"label": "Cambodia"
},
{
"value": "CMR",
"label": "Cameroon"
},
{
"value": "CAN",
"label": "Canada"
},
{
"value": "CYM",
"label": "Cayman Islands"
},
{
"value": "CAF",
"label": "Central African Republic"
},
{
"value": "TCD",
"label": "Chad"
},
{
"value": "CHL",
"label": "Chile"
},
{
"value": "CHN",
"label": "China"
},
{
"value": "CXR",
"label": "Christmas Island"
},
{
"value": "CCK",
"label": "Cocos (Keeling) Islands"
},
{
"value": "COL",
"label": "Colombia"
},
{
"value": "COM",
"label": "Comoros"
},
{
"value": "COK",
"label": "Cook Islands"
},
{
"value": "CRI",
"label": "Costa Rica"
},
{
"value": "CIV",
"label": "Côte d’Ivoire"
},
{
"value": "HRV",
"label": "Croatia"
},
{
"value": "CUB",
"label": "Cuba"
},
{
"value": "CUW",
"label": "Curaçao"
},
{
"value": "CYP",
"label": "Cyprus"
},
{
"value": "DNK",
"label": "Denmark"
},
{
"value": "DJI",
"label": "Djibouti"
},
{
"value": "DMA",
"label": "Dominica"
},
{
"value": "DOM",
"label": "Dominican Republic"
},
{
"value": "ECU",
"label": "Ecuador"
},
{
"value": "EGY",
"label": "Egypt"
},
{
"value": "SLV",
"label": "El Salvador"
},
{
"value": "GNQ",
"label": "Equatorial Guinea"
},
{
"value": "ERI",
"label": "Eritrea"
},
{
"value": "EST",
"label": "Estonia"
},
{
"value": "SWZ",
"label": "Eswatini"
},
{
"value": "ETH",
"label": "Ethiopia"
},
{
"value": "FLK",
"label": "Falkland Islands (Islas Malvinas)"
},
{
"value": "FRO",
"label": "Faroe Islands"
},
{
"value": "FJI",
"label": "Fiji"
},
{
"value": "FIN",
"label": "Finland"
},
{
"value": "FRA",
"label": "France"
},
{
"value": "GUF",
"label": "French Guiana"
},
{
"value": "PYF",
"label": "French Polynesia"
},
{
"value": "GAB",
"label": "Gabon"
},
{
"value": "GEO",
"label": "Georgia"
},
{
"value": "DEU",
"label": "Germany"
},
{
"value": "GHA",
"label": "Ghana"
},
{
"value": "GIB",
"label": "Gibraltar"
},
{
"value": "GRC",
"label": "Greece"
},
{
"value": "GRL",
"label": "Greenland"
},
{
"value": "GRD",
"label": "Grenada"
},
{
"value": "GLP",
"label": "Guadeloupe"
},
{
"value": "GUM",
"label": "Guam"
},
{
"value": "GTM",
"label": "Guatemala"
},
{
"value": "GGY",
"label": "Guernsey"
},
{
"value": "GIN",
"label": "Guinea"
},
{
"value": "GNB",
"label": "Guinea-Bissau"
},
{
"value": "GUY",
"label": "Guyana"
},
{
"value": "HTI",
"label": "Haiti"
},
{
"value": "HND",
"label": "Honduras"
},
{
"value": "HKG",
"label": "Hong Kong"
},
{
"value": "HUN",
"label": "Hungary"
},
{
"value": "ISL",
"label": "Iceland"
},
{
"value": "IND",
"label": "India"
},
{
"value": "IDN",
"label": "Indonesia"
},
{
"value": "IRN",
"label": "Iran"
},
{
"value": "IRQ",
"label": "Iraq"
},
{
"value": "IRL",
"label": "Ireland"
},
{
"value": "IMN",
"label": "Isle of Man"
},
{
"value": "ISR",
"label": "Israel"
},
{
"value": "ITA",
"label": "Italy"
},
{
"value": "JAM",
"label": "Jamaica"
},
{
"value": "JPN",
"label": "Japan"
},
{
"value": "JEY",
"label": "Jersey"
},
{
"value": "JOR",
"label": "Jordan"
},
{
"value": "KAZ",
"label": "Kazakhstan"
},
{
"value": "KEN",
"label": "Kenya"
},
{
"value": "KIR",
"label": "Kiribati"
},
{
"value": "KOS",
"label": "Kosovo"
},
{
"value": "KWT",
"label": "Kuwait"
},
{
"value": "KGZ",
"label": "Kyrgyzstan"
},
{
"value": "LAO",
"label": "Laos"
},
{
"value": "LVA",
"label": "Latvia"
},
{
"value": "LBN",
"label": "Lebanon"
},
{
"value": "LSO",
"label": "Lesotho"
},
{
"value": "LBR",
"label": "Liberia"
},
{
"value": "LBY",
"label": "Libya"
},
{
"value": "LIE",
"label": "Liechtenstein"
},
{
"value": "LTU",
"label": "Lithuania"
},
{
"value": "LUX",
"label": "Luxembourg"
},
{
"value": "MAC",
"label": "Macao"
},
{
"value": "MDG",
"label": "Madagascar"
},
{
"value": "MWI",
"label": "Malawi"
},
{
"value": "MYS",
"label": "Malaysia"
},
{
"value": "MLI",
"label": "Mali"
},
{
"value": "MLT",
"label": "Malta"
},
{
"value": "MTQ",
"label": "Martinique"
},
{
"value": "MRT",
"label": "Mauritania"
},
{
"value": "MUS",
"label": "Mauritius"
},
{
"value": "MYT",
"label": "Mayotte"
},
{
"value": "MEX",
"label": "Mexico"
},
{
"value": "MDA",
"label": "Moldova"
},
{
"value": "MCO",
"label": "Monaco"
},
{
"value": "MNG",
"label": "Mongolia"
},
{
"value": "MNE",
"label": "Montenegro"
},
{
"value": "MSR",
"label": "Montserrat"
},
{
"value": "MAR",
"label": "Morocco"
},
{
"value": "MOZ",
"label": "Mozambique"
},
{
"value": "MMR",
"label": "Myanmar"
},
{
"value": "NAM",
"label": "Namibia"
},
{
"value": "NRU",
"label": "Nauru"
},
{
"value": "NPL",
"label": "Nepal"
},
{
"value": "NCL",
"label": "New Caledonia"
},
{
"value": "NZL",
"label": "New Zealand"
},
{
"value": "NIC",
"label": "Nicaragua"
},
{
"value": "NER",
"label": "Niger"
},
{
"value": "NGA",
"label": "Nigeria"
},
{
"value": "NIU",
"label": "Niue"
},
{
"value": "NFK",
"label": "Norfolk Island"
},
{
"value": "PRK",
"label": "North Korea"
},
{
"value": "MKD",
"label": "North Macedonia"
},
{
"value": "NOR",
"label": "Norway"
},
{
"value": "OMN",
"label": "Oman"
},
{
"value": "PAK",
"label": "Pakistan"
},
{
"value": "PLW",
"label": "Palau"
},
{
"value": "PSE",
"label": "Palestine"
},
{
"value": "PAN",
"label": "Panama"
},
{
"value": "PNG",
"label": "Papua New Guinea"
},
{
"value": "PRY",
"label": "Paraguay"
},
{
"value": "PER",
"label": "Peru"
},
{
"value": "POL",
"label": "Poland"
},
{
"value": "PRT",
"label": "Portugal"
},
{
"value": "PRI",
"label": "Puerto Rico"
},
{
"value": "QAT",
"label": "Qatar"
},
{
"value": "REU",
"label": "Reunion"
},
{
"value": "ROU",
"label": "Romania"
},
{
"value": "RWA",
"label": "Rwanda"
},
{
"value": "BLM",
"label": "Saint Barthelemy"
},
{
"value": "SHN",
"label": "Saint Helena, Ascension, and Tristan da Cunha"
},
{
"value": "KNA",
"label": "Saint Kitts and Nevis"
},
{
"value": "LCA",
"label": "Saint Lucia"
},
{
"value": "MAF",
"label": "Saint Martin"
},
{
"value": "SPM",
"label": "Saint Pierre and Miquelon"
},
{
"value": "VCT",
"label": "Saint Vincent and the Grenadines"
},
{
"value": "WSM",
"label": "Samoa"
},
{
"value": "SMR",
"label": "San Marino"
},
{
"value": "STP",
"label": "Sao Tome and Principe"
},
{
"value": "SAU",
"label": "Saudi Arabia"
},
{
"value": "SEN",
"label": "Senegal"
},
{
"value": "SRB",
"label": "Serbia"
},
{
"value": "SYC",
"label": "Seychelles"
},
{
"value": "SLE",
"label": "Sierra Leone"
},
{
"value": "SGP",
"label": "Singapore"
},
{
"value": "SXM",
"label": "Sint Maarten"
},
{
"value": "SVK",
"label": "Slovakia"
},
{
"value": "SVN",
"label": "Slovenia"
},
{
"value": "SOM",
"label": "Somalia"
},
{
"value": "ZAF",
"label": "South Africa"
},
{
"value": "KOR",
"label": "South Korea"
},
{
"value": "SSD",
"label": "South Sudan"
},
{
"value": "ESP",
"label": "Spain"
},
{
"value": "LKA",
"label": "Sri Lanka"
},
{
"value": "SDN",
"label": "Sudan"
},
{
"value": "SUR",
"label": "Suriname"
},
{
"value": "SJM",
"label": "Svalbard"
},
{
"value": "SWE",
"label": "Sweden"
},
{
"value": "CHE",
"label": "Switzerland"
},
{
"value": "SYR",
"label": "Syria"
},
{
"value": "TWN",
"label": "Taiwan"
},
{
"value": "TJK",
"label": "Tajikistan"
},
{
"value": "TZA",
"label": "Tanzania"
},
{
"value": "THA",
"label": "Thailand"
},
{
"value": "BHS",
"label": "The Bahamas"
},
{
"value": "VGB",
"label": "the British Virgin Islands"
},
{
"value": "CZE",
"label": "the Czech Republic"
},
{
"value": "COD",
"label": "the Democratic Republic of the Congo"
},
{
"value": "FSM",
"label": "the Federated States of Micronesia"
},
{
"value": "GMB",
"label": "The Gambia"
},
{
"value": "MDV",
"label": "the Maldives"
},
{
"value": "MHL",
"label": "the Marshall Islands"
},
{
"value": "NLD",
"label": "the Netherlands"
},
{
"value": "MNP",
"label": "the Northern Mariana Islands"
},
{
"value": "PHL",
"label": "the Philippines"
},
{
"value": "PCN",
"label": "the Pitcairn Islands"
},
{
"value": "COG",
"label": "the Republic of the Congo"
},
{
"value": "RUS",
"label": "the Russian Federation"
},
{
"value": "SLB",
"label": "the Solomon Islands"
},
{
"value": "TCA",
"label": "the Turks and Caicos Islands"
},
{
"value": "ARE",
"label": "the United Arab Emirates"
},
{
"value": "GBR",
"label": "the United Kingdom"
},
{
"value": "USA",
"label": "the United States"
},
{
"value": "VIR",
"label": "the Virgin Islands"
},
{
"value": "TLS",
"label": "Timor-Leste"
},
{
"value": "TGO",
"label": "Togo"
},
{
"value": "TKL",
"label": "Tokelau"
},
{
"value": "TON",
"label": "Tonga"
},
{
"value": "TTO",
"label": "Trinidad and Tobago"
},
{
"value": "TUN",
"label": "Tunisia"
},
{
"value": "TUR",
"label": "Turkiye"
},
{
"value": "TKM",
"label": "Turkmenistan"
},
{
"value": "TUV",
"label": "Tuvalu"
},
{
"value": "UGA",
"label": "Uganda"
},
{
"value": "UKR",
"label": "Ukraine"
},
{
"value": "URY",
"label": "Uruguay"
},
{
"value": "UZB",
"label": "Uzbekistan"
},
{
"value": "VUT",
"label": "Vanuatu"
},
{
"value": "VAT",
"label": "Vatican City"
},
{
"value": "VEN",
"label": "Venezuela"
},
{
"value": "VNM",
"label": "Vietnam"
},
{
"value": "WLF",
"label": "Wallis and Futuna"
},
{
"value": "ESH",
"label": "Western Sahara"
},
{
"value": "YEM",
"label": "Yemen"
},
{
"value": "ZMB",
"label": "Zambia"
},
{
"value": "ZWE",
"label": "Zimbabwe"
}
]

View file

@ -37,10 +37,17 @@ import { qrSvg } from './qr.js';
// sleeves and boxes are built around, and the one people already associate with a
// deck you hold and deal out. Bleed is included in the page so a trimmed card
// keeps its full-bleed portrait.
// ★ LANDSCAPE IS NOT JUST TRANSPOSED DIMENSIONS. The portrait card stacks the
// photo ABOVE the text (portrait occupies the top 46%); at 3.5 x 2.5in that same
// band is a strip too short to carry a face or leave room for the rows beneath it.
// A landscape card puts the photo BESIDE the text instead — so `side: true` is a
// LAYOUT switch the CSS reads, not a cosmetic flag.
export const STOCK = {
poker: { w: 2.5, h: 3.5, label: 'Poker (2.5 x 3.5 in)' },
postcard: { w: 4, h: 6, label: 'Postcard (4 x 6 in)' },
a6: { w: 4.13, h: 5.83, label: 'A6 (105 x 148 mm)' }, // explicit in, NOT "A6"
poker: { w: 2.5, h: 3.5, label: 'Card 2.5 × 3.5 in' },
pokerLandscape: { w: 3.5, h: 2.5, label: 'Card 3.5 × 2.5 in (landscape)', side: true },
postcard: { w: 4, h: 6, label: 'Postcard 4 × 6 in' },
postcardLandscape: { w: 6, h: 4, label: 'Postcard 6 × 4 in (landscape)', side: true },
a6: { w: 4.13, h: 5.83, label: 'A6 105 × 148 mm' }, // explicit in, NOT "A6"
};
const PX = 96; // CSS px per inch
@ -73,6 +80,10 @@ export function createCardRenderer({ strings, lang = 'en', stock = 'poker',
const S = STOCK[stock] || STOCK.poker;
const W = Math.round(S.w * PX); // card box in CSS px
const H = Math.round(S.h * PX);
// Photo BESIDE the text rather than above it. Driven by the stock, not by
// comparing W to H: a square stock would be ambiguous, and the choice is a
// deliberate property of the format rather than an inference from its numbers.
const SIDE = S.side === true;
// The live profile URL for this group, in this language. English has no prefix
// (the generator's own rule); every other language does.
@ -93,26 +104,36 @@ export function createCardRenderer({ strings, lang = 'en', stock = 'poker',
page-break-after:always;break-after:page}
.card:last-child{page-break-after:auto;break-after:auto}
/* PORTRAIT: full-bleed across the top. A face is what makes this a prayer card
rather than a data tile — it is the largest thing on the card by design. */
.ph{position:absolute;left:0;top:0;width:${W}px;height:${Math.round(H * 0.46)}px;
object-fit:cover;display:block;background:#ddd6c6}
.ph-none{position:absolute;left:0;top:0;width:${W}px;height:${Math.round(H * 0.46)}px;
background:linear-gradient(160deg,#e6dfcd,#cfc6ad);display:flex;
align-items:center;justify-content:center;
/* THE PHOTO. Portrait: full-bleed across the TOP, the largest thing on the card
by design — a face is what makes this a prayer card rather than a data tile.
Landscape: full-bleed down the LEFT instead, because a 46% band on a 2.5in-tall
card is a strip too short to carry a face AND leave room for the rows. */
.ph,.ph-none{position:absolute;left:0;top:0;display:block;
width:${SIDE ? Math.round(W * 0.42) : W}px;
height:${SIDE ? H : Math.round(H * 0.46)}px}
.ph{object-fit:cover;background:#ddd6c6}
.ph-none{background:linear-gradient(160deg,#e6dfcd,#cfc6ad);display:flex;
align-items:center;justify-content:center;text-align:center;padding:6px;
font:italic 11px Georgia,serif;color:#8c8170}
/* A soft scrim under the name so it stays legible over any photo. */
.scrim{position:absolute;left:0;top:${Math.round(H * 0.30)}px;width:${W}px;
height:${Math.round(H * 0.16)}px;
.scrim{position:absolute;left:0;
width:${SIDE ? Math.round(W * 0.42) : W}px;
top:${SIDE ? Math.round(H * 0.58) : Math.round(H * 0.30)}px;
height:${SIDE ? Math.round(H * 0.42) : Math.round(H * 0.16)}px;
background:linear-gradient(to bottom,rgba(24,22,18,0),rgba(24,22,18,.72))}
.nm{position:absolute;left:14px;right:14px;top:${Math.round(H * 0.355)}px;
font:700 19px Georgia,serif;color:#fff;line-height:1.05;
.nm{position:absolute;
left:${SIDE ? 10 : 14}px;
${SIDE ? `width:${Math.round(W * 0.42) - 20}px;` : `right:14px;`}
top:${SIDE ? Math.round(H * 0.74) : Math.round(H * 0.355)}px;
font:700 ${SIDE ? 15 : 19}px Georgia,serif;color:#fff;line-height:1.05;
text-shadow:0 1px 3px rgba(0,0,0,.5)}
.nm.long{font-size:15px}
.nm.long{font-size:${SIDE ? 12 : 15}px}
.body{position:absolute;left:14px;right:14px;top:${Math.round(H * 0.48)}px;
bottom:${Math.round(H * 0.20)}px;overflow:hidden}
.body{position:absolute;right:14px;overflow:hidden;
left:${SIDE ? Math.round(W * 0.42) + 12 : 14}px;
top:${SIDE ? 12 : Math.round(H * 0.48)}px;
bottom:${SIDE ? Math.round(H * 0.30) : Math.round(H * 0.20)}px}
.row{display:flex;gap:6px;font:400 9.5px Georgia,serif;line-height:1.5;margin-bottom:1px}
.k{color:#8c8170;min-width:52px;flex:0 0 auto}
.v{color:#3b3a34;font-weight:600}
@ -120,7 +141,8 @@ export function createCardRenderer({ strings, lang = 'en', stock = 'poker',
/* FOOTER: the QR is the card's whole reason to survive the day it is handed
over — it carries the reader to the live profile in their own language. */
.ft{position:absolute;left:14px;right:14px;bottom:10px;
.ft{position:absolute;right:14px;bottom:10px;
left:${SIDE ? Math.round(W * 0.42) + 12 : 14}px;
display:flex;align-items:flex-end;gap:8px}
.qr{flex:0 0 auto;line-height:0}
.ftx{flex:1 1 auto;min-width:0}

View file

@ -26,7 +26,23 @@
// so a new Arabic-script locale is one entry here and not a schema change.
const RTL = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi']);
export function createPacketRenderer({ strings, lang = 'en' }) {
// Scripts with no coverage in the packet's Georgia/Palatino/serif stack, mapped to
// the vendored face that does cover them. MEASURED by rendering each script at 48px
// and reading the PIXELS: Latin, Cyrillic, Arabic and CJK all shape correctly with
// system fonts; DEVANAGARI renders as tofu boxes.
// ⚠ A width heuristic got this exactly backwards — it reported Arabic and Chinese
// broken and Hindi fine, because Devanagari tofu boxes happen to match real glyph
// advance. Never infer glyph coverage from measurements; look at the pixels.
const SCRIPT_FONT = {
hi: { family: 'Noto Serif Devanagari', dir: 'noto-serif-devanagari',
file: 'noto-serif-devanagari-400-devanagari.woff2' },
mr: { family: 'Noto Serif Devanagari', dir: 'noto-serif-devanagari',
file: 'noto-serif-devanagari-400-devanagari.woff2' }, // Marathi, same script
ne: { family: 'Noto Serif Devanagari', dir: 'noto-serif-devanagari',
file: 'noto-serif-devanagari-400-devanagari.woff2' }, // Nepali, same script
};
export function createPacketRenderer({ strings, lang = 'en', fontData = null }) {
const EN = strings.en;
const L = strings[lang] || {};
const t = (k) => (L[k] !== undefined ? L[k] : EN[k]);
@ -56,8 +72,28 @@ export function createPacketRenderer({ strings, lang = 'en' }) {
const sheet = (inner, cls = '') =>
`<div class="sheet ${cls}">${inner}</div>`;
// The script font for THIS language, if it needs one. `fontData` is a base64
// woff2 the caller supplies: the packet must stay a SELF-CONTAINED file that
// prints offline (the Node generator writes standalone HTML with no server, and
// photos are already inlined for the same reason), so a URL would break exactly
// where the packet is most used. Absent fontData, the rule is simply omitted and
// the stack falls back to serif — a legible wrong font beats a broken document.
const SF = SCRIPT_FONT[lang] || null;
const fontFace = (SF && fontData)
? `@font-face{font-family:'${SF.family}';font-style:normal;font-weight:400;`
+ `font-display:swap;src:url(data:font/woff2;base64,${fontData}) format('woff2')}`
: '';
// Prepend the script face to EVERY element's stack in one rule rather than
// editing 22 hardcoded Georgia declarations. The browser falls through per
// CHARACTER, so Latin text in a Hindi packet still renders in Georgia.
const scriptStack = SF
? `.sheet,.sheet *{font-family:'${SF.family}',Georgia,'Palatino Linotype',serif !important}`
: '';
function css() {
return `
${fontFace}
${scriptStack}
@page{size:letter portrait;margin:0}
html,body{margin:0;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact}
.sheet{position:relative;width:${W}px;height:${H}px;background:#f7f4ec;overflow:hidden;

View file

@ -4,7 +4,7 @@
"how_to_translate": "Copy the \"en\" block, change the key to the ISO code, translate the values. Keep {placeholders} exactly as they are — they are substituted at render time. Do not translate scripture REFERENCES (book names are resolved by the reader's own Bible); DO translate the prayer text.",
"note_on_scripture": "point4_passage is the ONLY full scripture text in the packet (Mark 5:1-20, printed in full at the Driver's instruction). A translation MUST supply its own rendering in a public-domain or properly-licensed version FOR THAT LANGUAGE. ⚠ Do NOT translate it yourself and do NOT leave a machine rendering in place: this is printed scripture in a prayer packet. A language block that omits point4_passage falls back to the English WEB text per-key, which is honest — an untrusted translation of Mark 5 is not.",
"note_on_references": "Scripture REFERENCES (Matthew 27:27, Mark 5:1-20) are deliberately left in English in every language block, so a reader can look them up in their own Bible regardless of the packet's language. ⚠ MEASURED TRADEOFF, not an oversight: on a rendered French page the English book names are visibly foreign next to fully-translated prayer text. If a language wants localised book names, add a `books` map to that block and teach the renderer to use it — do NOT translate the refs inline, because the same strings identify the passage for any future lookup.",
"note_on_scripts": "MEASURED 2026-09-24 by rendering each script at 48px and READING THE PIXELS: Latin, Cyrillic (ru), Arabic (ar) and CJK (zh) all shape correctly in the packet's Georgia/serif stack. DEVANAGARI (hi) renders as TOFU BOXES — this box has zero Devanagari fonts installed (fc-list). So hi is BLOCKED on vendoring a Devanagari font (e.g. Noto Serif Devanagari) into public/assets/fonts and adding it to the packet's font stack; adding a hi block before then would ship a page of empty rectangles. ⚠ A WIDTH HEURISTIC GOT THIS BACKWARDS: comparing rendered advance against a missing-font baseline reported ar and zh as broken and hi as fine — the exact opposite of the truth, because Devanagari tofu boxes happen to match real glyph advance. Read the pixels.",
"note_on_scripts": "ALL 11 app locales now ship. Script coverage MEASURED by rendering at 48px and READING THE PIXELS: Latin, Cyrillic (ru), Arabic (ar) and CJK (zh) shape correctly with the system Georgia/Palatino/serif stack. DEVANAGARI (hi) does NOT — it renders as tofu boxes — so Noto Serif Devanagari is vendored (OFL) and base64-INLINED into hi packets by the renderer's SCRIPT_FONT map. Inlined, not linked, because a packet is a standalone file that must print offline; ~165KB on a 3.5MB packet, and only for that language. mr and ne map to the same face.\n⚠ TWO MEASUREMENTS LIED ABOUT THIS, so do not automate it cheaply: (1) comparing shaped width against a missing font reported ar and zh BROKEN and hi FINE — exactly backwards, because Devanagari tofu boxes match real glyph advance; (2) an ink-column-profile heuristic scored CORRECTLY-RENDERED Devanagari the same as tofu (3 vs 2 distinct heights), because its baseline bar gives a uniform profile. check:packet-render therefore asserts only that a DECLARED @font-face LOADED — the mechanical half — and leaves glyph correctness to a human reading a screenshot. A detector that blocks working packets is worse than none.",
"note_on_rtl": "ar is right-to-left. The renderer emits dir on <html> from an RTL set (packet-render.js), and scripture REFERENCES are wrapped in dir=ltr + unicode-bidi:isolate. Without that isolation the bidi algorithm reorders a reference that STARTS WITH A DIGIT: '1 Peter 5:6-11' rendered as 'Peter 5:6-11 1'. Note the reference LIST then reads right-to-left on an RTL page (first item rightmost) — that is correct for an RTL reader, not a defect."
},
"en": {
@ -1430,5 +1430,321 @@
]
}
]
},
"hi": {
"map_page_kicker": "अनछुए जनसमूह",
"map_page_sub": "{count} अनछुए जनसमूहों की प्रार्थना सूची",
"list_header": "{count} में से {from}–{to} · उनके नाम लेकर प्रार्थना करें",
"people_suffix": "लोग",
"no_photo": "कोई चित्र उपलब्ध नहीं",
"deck_title": "प्रार्थना कार्ड",
"card_country": "देश",
"card_population": "जनसंख्या",
"card_language": "भाषा",
"card_religion": "धर्म",
"card_cta": "उनके लिए प्रार्थना करें",
"points_title": "12 प्रार्थना बिंदु",
"points_intro": "परमेश्वर के साथ एक घंटा, हर बिंदु के लिए पाँच मिनट। इस पैकेट के जनसमूहों के लिए ये बारह बिंदु प्रार्थना करें।",
"points_attrib": "प्रार्थना लय Metacamp से प्रेरित · metacamp.org",
"wheel_title": "प्रार्थना चक्र",
"wheel_intro": "परमेश्वर के साथ एक घंटा बिताना? यह आपके सोचने से आसान है। इसे 5 मिनट के हिस्सों में बाँटें और हर हिस्से में इनमें से एक पर ध्यान दें।",
"wheel_minutes": "मिनट",
"wheel_attrib": "प्रार्थना चक्र · Metacamp मॉड्यूल 1 · metacamp.org",
"privacy_note": "चिह्नों की स्थिति अनुमानित है · गोपनीयता के लिए जनसमूहों के स्थान स्रोत पर सामान्यीकृत किए गए हैं",
"rhythms": {
"praise": "स्तुति",
"wait": "प्रतीक्षा",
"confess": "अंगीकार",
"read": "वचन पढ़ना",
"ask": "माँगना",
"intercede": "मध्यस्थता",
"pray_word": "वचन से प्रार्थना",
"thank": "धन्यवाद",
"sing": "गायन",
"meditate": "मनन",
"listen": "सुनना",
"praise_end": "स्तुति"
},
"points": [
{
"n": 1,
"rhythm": "praise",
"sub": "दो क्रूसों का सुसमाचार",
"text": "कि वे मसीह को राजा मानकर ग्रहण करें, उसके क्रूस से क्षमा पाएँ — और अपना क्रूस उठाएँ, सताव के क्षेत्र में दुख उठाने को तैयार। मेम्ने को उसके दुख का प्रतिफल मिले, मुझसे आरंभ करके।",
"refs": [
"Matthew 27:27",
"Matthew 16:24–25"
]
},
{
"n": 2,
"rhythm": "wait",
"sub": "पवित्र आत्मा और छुटकारा",
"text": "कि वे आत्मा से भरें और साहसी गवाह बनें; अकेले न रहें; जल और आत्मा से बपतिस्मा पाएँ; बुराई की हर पकड़ टूट जाए। कि पड़ोसी जातियों के विश्वासी मज़दूरों के रूप में उठ खड़े हों।",
"refs": [
"Acts 1:8",
"John 14:26"
]
},
{
"n": 3,
"rhythm": "confess",
"sub": "",
"text": "हम इस अविश्वास को मानते हैं कि यह परमेश्वर के लिए बहुत बड़ा है। कि वह संसार के अनछुए लोगों के लिए हमारे प्रेम और विश्वास को फिर से जगाए।",
"refs": [
"John 3:16",
"1 John 1:9"
]
},
{
"n": 4,
"rhythm": "read",
"sub": "पिता का प्रेम",
"text": "कि वे उद्धार पाएँ और जानें कि उनका स्वर्गीय पिता उनसे प्रेम करता है — और उनके द्वारा वह उनके परिवार, मित्रों, पड़ोसियों, सहकर्मियों, सहपाठियों, उन सबके पास उद्धार पहुँचाए जिन्हें वे व्यक्तिगत रूप से जानते हैं।",
"refs": [
"Mark 5:1–20",
"Acts 16:31"
],
"print_passage": true
},
{
"n": 5,
"rhythm": "ask",
"sub": "बपतिस्मा",
"text": "स्थानीय लोगों में नए बपतिस्मा हों, और हर बपतिस्मा पाया हुआ व्यक्ति दूसरों को बपतिस्मा देने के लिए प्रशिक्षित हो, और यह बढ़ता जाए।",
"refs": [
"Acts 16",
"Acts 10"
]
},
{
"n": 6,
"rhythm": "intercede",
"sub": "मज़दूर और चरवाहे",
"text": "मज़दूर भेजे जाएँ जो उस जाति से गहरा प्रेम करें जिससे वे कभी नहीं मिले; और भीतर से अच्छे चरवाहे उठाए जाएँ।",
"refs": [
"Luke 10:2",
"John 10:11–15"
]
},
{
"n": 7,
"rhythm": "pray_word",
"sub": "शांति के जन और घर",
"text": "कि मज़दूर और स्थानीय अगुवे ग्रहणशील व्यक्ति और शांति के घर को पाएँ, और उन्हें भली भाँति चेला बनाएँ।",
"refs": [
"Luke 10:5–7",
"Matthew 10:5–14"
]
},
{
"n": 8,
"rhythm": "thank",
"sub": "विश्राम",
"text": "परमेश्वर का धन्यवाद कि उसने विश्राम दिया है; कि वे मसीह में उस विश्राम में प्रवेश करें, और उनका आत्मिक परिवार एक सुरक्षित स्थान हो।",
"refs": [
"Mark 4:26–29",
"Hebrews 4:9–11"
]
},
{
"n": 9,
"rhythm": "sing",
"sub": "",
"text": "कि वह जनसमूह आराधकों से भर जाए — हर जगह आराधना, स्वतंत्रता में और बेड़ियों में भी।",
"refs": [
"Acts 16:25–26",
"Colossians 3:16"
]
},
{
"n": 10,
"rhythm": "meditate",
"sub": "प्रभु भोज",
"text": "कि वे मिलकर प्रभु भोज लें और उसकी मृत्यु, गाड़े जाने और पुनरुत्थान के भार को समझें।",
"refs": [
"Matthew 26:26–30",
"1 Corinthians 11:23–26"
]
},
{
"n": 11,
"rhythm": "listen",
"sub": "जवाबदेही",
"text": "केवल यह नहीं कि वे सुनें, परन्तु यह कि उनके जीवन में ऐसे लोग हों जो उन्हें वचन पढ़ने, प्रार्थना करने, संगति में लगे रहने और दुख का सही उत्तर देने में सहायता करें — उस शत्रु का सामना करते हुए जो सारे संसार में विश्वासियों के पीछे पड़ा है।",
"refs": [
"1 Peter 5:6–11",
"Hebrews 10:24–25"
]
},
{
"n": 12,
"rhythm": "praise_end",
"sub": "महान · और महान · सबसे महान",
"text": "पहले से स्तुति — उद्धार के लिए; और महान, बढ़ते हुए आत्मिक परिवारों के लिए; सबसे महान, समर्पित जीवनों और उठाए गए स्थानीय कलीसिया-रोपकों के लिए जो पूरे जनसमूह में स्वस्थ कलीसियाएँ बढ़ाते हैं, चाहे मूल्य कुछ भी हो।",
"refs": [
"Acts 2:42–47",
"Matthew 28:19–20"
]
}
]
},
"zh": {
"map_page_kicker": "未得之民",
"map_page_sub": "為 {count} 個未得之民的禱告名單",
"list_header": "{count} 之中的 {from}–{to} · 按名為他們禱告",
"people_suffix": "人",
"no_photo": "沒有照片",
"deck_title": "禱告卡",
"card_country": "國家",
"card_population": "人口",
"card_language": "語言",
"card_religion": "宗教",
"card_cta": "為他們禱告",
"points_title": "12 個禱告要點",
"points_intro": "與神同在一小時,每個要點五分鐘。為這份禱告冊中的族群禱告這十二個要點。",
"points_attrib": "禱告節奏取材自 Metacamp · metacamp.org",
"wheel_title": "禱告輪",
"wheel_intro": "與神同在一小時?比你想的容易。把它分成五分鐘一段,每一段專注其中一個要點。",
"wheel_minutes": "分鐘",
"wheel_attrib": "禱告輪 · Metacamp 第一單元 · metacamp.org",
"privacy_note": "標記位置為概略位置 · 族群所在地已於資料來源處作概化處理以保護隱私",
"rhythms": {
"praise": "讚美",
"wait": "等候",
"confess": "認罪",
"read": "讀神的話",
"ask": "祈求",
"intercede": "代禱",
"pray_word": "以神的話禱告",
"thank": "感謝",
"sing": "歌唱",
"meditate": "默想",
"listen": "聆聽",
"praise_end": "讚美"
},
"points": [
{
"n": 1,
"rhythm": "praise",
"sub": "兩個十字架的福音",
"text": "願他們接受基督為王,藉著祂的十字架得赦免——也背起自己的十字架,甘願在逼迫之地受苦。願羔羊得著祂受苦的賞賜,從我開始。",
"refs": [
"Matthew 27:27",
"Matthew 16:24–25"
]
},
{
"n": 2,
"rhythm": "wait",
"sub": "聖靈與釋放",
"text": "願他們被聖靈充滿,成為剛強的見證人;不至孤單;受水和聖靈的洗;一切邪惡的轄制都被打破。願鄰近族群中的信徒被興起作工人。",
"refs": [
"Acts 1:8",
"John 14:26"
]
},
{
"n": 3,
"rhythm": "confess",
"sub": "",
"text": "我們承認自己的不信,以為這事對神太難。願祂重新點燃我們對普世未得之民的愛與信心。",
"refs": [
"John 3:16",
"1 John 1:9"
]
},
{
"n": 4,
"rhythm": "read",
"sub": "天父的愛",
"text": "願他們得救,並知道天父愛他們——也願神藉著他們,把救恩帶給他們的家人、朋友、鄰舍、同事、同學,以及他們所認識的每一個人。",
"refs": [
"Mark 5:1–20",
"Acts 16:31"
],
"print_passage": true
},
{
"n": 5,
"rhythm": "ask",
"sub": "洗禮",
"text": "願當地有新的受洗者,並且每一位受洗的人都被裝備去為別人施洗,不斷繁增。",
"refs": [
"Acts 16",
"Acts 10"
]
},
{
"n": 6,
"rhythm": "intercede",
"sub": "工人與牧人",
"text": "願工人被差派去深愛一群素未謀面的人;也願好牧人從他們中間被興起。",
"refs": [
"Luke 10:2",
"John 10:11–15"
]
},
{
"n": 7,
"rhythm": "pray_word",
"sub": "平安之子與平安之家",
"text": "願工人與當地帶領者尋得那樂意接待的人和平安之家,並好好門訓他們。",
"refs": [
"Luke 10:5–7",
"Matthew 10:5–14"
]
},
{
"n": 8,
"rhythm": "thank",
"sub": "安息",
"text": "感謝神所賜的安息;願他們在基督裡進入那安息,也願他們的屬靈家庭是安全的地方。",
"refs": [
"Mark 4:26–29",
"Hebrews 4:9–11"
]
},
{
"n": 9,
"rhythm": "sing",
"sub": "",
"text": "願這族群充滿敬拜者——處處敬拜,在自由中,也在捆鎖中。",
"refs": [
"Acts 16:25–26",
"Colossians 3:16"
]
},
{
"n": 10,
"rhythm": "meditate",
"sub": "聖餐",
"text": "願他們一同領受聖餐,深深體會祂的死、埋葬與復活的份量。",
"refs": [
"Matthew 26:26–30",
"1 Corinthians 11:23–26"
]
},
{
"n": 11,
"rhythm": "listen",
"sub": "彼此守望",
"text": "不只願他們聆聽,更願他們身邊有人幫助他們讀神的話、禱告、委身群體,並在苦難中正確回應——抵擋那在全世界追逼信徒的仇敵。",
"refs": [
"1 Peter 5:6–11",
"Hebrews 10:24–25"
]
},
{
"n": 12,
"rhythm": "praise_end",
"sub": "大 · 更大 · 最大",
"text": "先行讚美——為著得救的人;更大的,為著不斷繁增的屬靈家庭;最大的,為著獻上的生命,以及被興起的本地植堂者,無論代價如何,都在整個族群中繁增健康的教會。",
"refs": [
"Acts 2:42–47",
"Matthew 28:19–20"
]
}
]
}
}

View file

@ -74,7 +74,30 @@ const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (_, k) => (vars[k] ??
// the SAME packet from the SAME code. Changing a page here changes it in both.
const { createPacketRenderer } = await import(
join(__dirname, '..', '..', 'packet', 'packet-render.js'));
const R = createPacketRenderer({ strings: STRINGS, lang: LANG });
// SCRIPT FONT, inlined. Some languages need a face the system stack does not
// carry — Hindi renders as tofu boxes in Georgia/Palatino/serif. The packet is a
// standalone file meant to print offline, so the font is base64'd INTO it rather
// than linked, exactly as the photos are. Absent, the renderer omits the rule and
// falls back to serif rather than failing.
const SCRIPT_FONT_FILE = {
hi: 'noto-serif-devanagari/noto-serif-devanagari-400-devanagari.woff2',
mr: 'noto-serif-devanagari/noto-serif-devanagari-400-devanagari.woff2',
ne: 'noto-serif-devanagari/noto-serif-devanagari-400-devanagari.woff2',
};
let fontData = null;
const fontRel = SCRIPT_FONT_FILE[LANG];
if (fontRel) {
const fp = join(__dirname, '..', '..', '..', 'public', 'assets', 'fonts', fontRel);
if (existsSync(fp)) {
fontData = readFileSync(fp).toString('base64');
} else {
// Loud, not silent: a missing font here means the packet prints as empty
// rectangles, which looks like a content bug rather than a missing asset.
console.warn(`[packet] ⚠ ${LANG} needs ${fontRel} and it is MISSING — `
+ 'the packet will render as tofu boxes. Vendor the font first.');
}
}
const R = createPacketRenderer({ strings: STRINGS, lang: LANG, fontData });
const { mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H } = R;
// ── DATA ────────────────────────────────────────────────────────────────────

View file

@ -0,0 +1,283 @@
// countries-meta.js — a PORT of DX-life's config/countries-meta.ts (326 lines), the rollup
// source of truth behind /regions, /regions/<region> and /regions/<country>.
//
// WHY IT IS HERE AND NOT IN THE PLUGIN FOLDER: two named consumers already had their own
// copy of this grouping logic — plugin-designs/regions-dashboard/regions-list.vue (tab 1)
// and .../region-dashboards.vue (tab 2). Two copies of a rollup is two chances for the
// region page and the dashboard to disagree about the same number, which is exactly the
// failure this file's own header warns about on their side ("the region totals, the
// per-country rows and the map's in-region pins all agree").
//
// WHAT CHANGED IN THE PORT, and nothing else:
// * TypeScript -> JavaScript. The interfaces become JSDoc; no logic was rewritten.
// * `import { COUNTRIES } from '../app/utils/countries'` becomes library/data/countries.json,
// which is their same 242-entry {value,label} array in their order.
// Everything else — slugify's replace chain, FULL_PRAYER_COVERAGE_COUNT, addToStats's
// conditions, fitView's WORLD_DIM/VIEW_W/VIEW_H/MAX_ZOOM/MIN_ZOOM/NEIGHBOUR_PADDING,
// lngExtent's largest-gap walk, WORLD_VIEW, and both sort orders — is theirs verbatim.
//
// THE TWO THINGS THE OLD COPIES GOT WRONG, both fixed by porting rather than patching:
// 1. They slugged a country from `g.name`, the LOCALIZED label off the API. Theirs slugs
// from the canonical ENGLISH name (ENGLISH_NAME_BY_CODE), deliberately: their comment
// says "a French page is still /fr/regions/india, not /fr/regions/inde". Since our
// dashboard writes the slug into the URL hash, the old behaviour made a deep link
// locale-dependent and so different from theirs.
// 2. They dropped center/zoom/bounds entirely ("minus the map bounds/zoom"), so our maps
// derived their own frame. Theirs computes a fit view per country AND per region.
import COUNTRIES from '../data/countries.json'
/** Fields requested from the prayer API's people-groups list for these summaries. */
export const PEOPLE_GROUP_LIST_FIELDS = 'country_code,wagf_region,latitude,longitude,population,adopted_by_churches,people_committed,engagement_status'
/** A people group counts as fully covered once this many people commit to daily prayer. */
export const FULL_PRAYER_COVERAGE_COUNT = 100
/**
* @typedef {Object} PeopleGroupLite
* @property {{value:string,label:string}|null} [country_code]
* @property {{value:string,label:string}|null} [wagf_region]
* @property {string|number|null} [latitude]
* @property {string|number|null} [longitude]
* @property {string|number|null} [population]
* @property {number|null} [adopted_by_churches]
* @property {string|number|null} [people_committed]
* @property {{value:string,label:string}|null} [engagement_status]
*/
/**
* Map bounds [[west, south], [east, north]] framing a set of pins. East may exceed 180 when
* the box crosses the antimeridian (e.g. Oceania), which Mapbox accepts as a continuous
* longitude range.
* @typedef {[[number,number],[number,number]]} MapBounds
*/
/**
* Progress figures shared by country and region summaries: count (people groups), population,
* adopted (by >=1 church), engaged (engagement_status 'engaged'), withPrayer (>=1 committed),
* withFullPrayer (>=FULL_PRAYER_COVERAGE_COUNT), peopleCommitted (the total).
* @typedef {{count:number,population:number,adopted:number,engaged:number,withPrayer:number,withFullPrayer:number,peopleCommitted:number}} ProgressStats
*/
// ISO-3 -> canonical English name, the basis for slugs. Sourced from the same country list
// the rest of the app uses so slugs stay consistent and locale-independent (a French page is
// still /fr/regions/india, not /fr/regions/inde).
const ENGLISH_NAME_BY_CODE = new Map(COUNTRIES.map(c => [c.value, c.label]))
export function slugify(name) {
return String(name || '')
.normalize('NFD').replace(/[̀-ͯ]/g, '') // strip diacritics
.toLowerCase()
.replace(/['’.]/g, '') // drop apostrophes / periods (Côte d'Ivoire -> cote-divoire)
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-') // any run of non-alphanumerics -> single hyphen
.replace(/^-+|-+$/g, '') // trim leading/trailing hyphens
}
// Slug for a country code. Prefers the canonical English name; falls back to a provided
// label (and finally the raw code) for any code not in the master list.
export function countrySlug(code, fallbackLabel) {
return slugify(ENGLISH_NAME_BY_CODE.get(code) || fallbackLabel || code)
}
// Slug for a WAGF region. Derived from the stable value rather than the localized label so
// every locale shares one URL (e.g. "middle_east" -> "middle-east"). No region -> "other".
export function regionSlug(value) {
return slugify(value) || 'other'
}
// EXPORTED BEYOND THEIR SURFACE — the only two, and why. Theirs keeps toNumber and
// emptyStats module-private because their pages never need a cross-region total: DX-life has
// no "all regions" view. Ours does, because the no-selection chooser is one of the two
// sanctioned additions, and it needs a zero accumulator (emptyStats) to roll every region up
// and a coordinate parser (toNumber) for the map's points array. Nothing else is added, and
// no logic differs.
export function toNumber(value) {
const n = typeof value === 'string' ? parseFloat(value) : (value ?? 0)
return Number.isNaN(n) ? 0 : n
}
export function emptyStats() {
return { count: 0, population: 0, adopted: 0, engaged: 0, withPrayer: 0, withFullPrayer: 0, peopleCommitted: 0 }
}
function addToStats(stats, p) {
const committed = toNumber(p.people_committed)
stats.count++
stats.population += toNumber(p.population)
if ((p.adopted_by_churches ?? 0) > 0) stats.adopted++
if (p.engagement_status?.value === 'engaged') stats.engaged++
if (committed > 0) stats.withPrayer++
if (committed >= FULL_PRAYER_COVERAGE_COUNT) stats.withFullPrayer++
stats.peopleCommitted += committed
}
function addPin(bounds, p) {
const lat = typeof p.latitude === 'string' ? parseFloat(p.latitude) : p.latitude
const lng = typeof p.longitude === 'string' ? parseFloat(p.longitude) : p.longitude
if (lat != null && lng != null && !Number.isNaN(lat) && !Number.isNaN(lng)) {
bounds.lats.push(lat)
bounds.lngs.push(lng)
}
}
function round(n) {
return Math.round(n * 100) / 100
}
// Web-Mercator latitude in radians, used by the fit-bounds zoom math.
function latRad(lat) {
const s = Math.sin((lat * Math.PI) / 180)
return Math.log((1 + s) / (1 - s)) / 2
}
// Compute a map center + zoom that frames the given bounding box. Zooms out slightly beyond
// a tight fit so surrounding countries remain in view (the whole point of the country page's
// map). The viewport dimensions are an approximation of the map slot; exact framing isn't
// important because the map keeps its own pan/zoom controls.
function fitView(minLng, minLat, maxLng, maxLat) {
const WORLD_DIM = 512
const VIEW_W = 1000
const VIEW_H = 560
const MAX_ZOOM = 6
const MIN_ZOOM = 1.6
const NEIGHBOUR_PADDING = 0.6 // zoom out this much so neighbours stay visible
const latFraction = Math.max((latRad(maxLat) - latRad(minLat)) / Math.PI, 1e-6)
let lngDiff = maxLng - minLng
if (lngDiff < 0) lngDiff += 360
const lngFraction = Math.max(lngDiff / 360, 1e-6)
const latZoom = Math.log2(VIEW_H / WORLD_DIM / latFraction)
const lngZoom = Math.log2(VIEW_W / WORLD_DIM / lngFraction)
let zoom = Math.min(latZoom, lngZoom, MAX_ZOOM) - NEIGHBOUR_PADDING
zoom = Math.max(MIN_ZOOM, Math.min(zoom, MAX_ZOOM))
return {
center: [round((minLng + maxLng) / 2), round((minLat + maxLat) / 2)],
zoom: round(zoom),
}
}
// Longitude extent covering all pins along the shortest arc: sorts the longitudes, finds the
// largest gap between neighbours (including the wrap-around gap past 180), and spans
// everything else. When the extent crosses the antimeridian the east edge exceeds 180 so the
// range stays continuous.
function lngExtent(lngs) {
const sorted = [...lngs].sort((a, b) => a - b)
const last = sorted.length - 1
let gapStart = last
let maxGap = sorted[0] + 360 - sorted[last]
for (let i = 0; i < last; i++) {
const gap = sorted[i + 1] - sorted[i]
if (gap > maxGap) {
maxGap = gap
gapStart = i
}
}
if (gapStart === last) return [sorted[0], sorted[last]]
return [sorted[gapStart + 1], sorted[gapStart] + 360]
}
// Default whole-world view for a set of records with no coordinates.
const WORLD_VIEW = { center: [20, 10], zoom: 1.8, bounds: null }
function viewFor(pins) {
if (pins.lats.length === 0) return WORLD_VIEW
const [west, east] = lngExtent(pins.lngs)
const south = Math.min(...pins.lats)
const north = Math.max(...pins.lats)
return { ...fitView(west, south, east, north), bounds: [[west, south], [east, north]] }
}
/**
* Group people-group records into one summary per country, sorted by name.
* @param {PeopleGroupLite[]} posts
*/
export function summarizeCountries(posts) {
const groups = new Map()
for (const p of posts) {
const code = p.country_code?.value
if (!code) continue
let g = groups.get(code)
if (!g) {
g = { name: p.country_code.label, region: { value: '', label: '' }, pins: { lats: [], lngs: [] }, stats: emptyStats() }
groups.set(code, g)
}
// A few records carry no region; the country takes the first region seen.
if (!g.region.value && p.wagf_region?.value) g.region = p.wagf_region
addToStats(g.stats, p)
addPin(g.pins, p)
}
const out = []
for (const [code, g] of groups) {
const view = viewFor(g.pins)
out.push({
slug: countrySlug(code, g.name),
code,
name: g.name,
region: g.region,
center: view.center,
zoom: view.zoom,
bounds: view.bounds,
...g.stats,
})
}
out.sort((a, b) => a.name.localeCompare(b.name))
return out
}
/**
* Group people-group records into one summary per WAGF region, sorted by label. Each people
* group rolls up under its country's region, so the region totals, the per-country rows and
* the map's in-region pins all agree.
* @param {PeopleGroupLite[]} posts
*/
export function summarizeRegions(posts) {
const countries = summarizeCountries(posts)
const regionSlugByCountry = new Map(countries.map(c => [c.code, regionSlug(c.region.value)]))
const groups = new Map()
for (const c of countries) {
const slug = regionSlugByCountry.get(c.code)
let g = groups.get(slug)
if (!g) {
g = { value: c.region.value, label: c.region.label, countries: [], pins: { lats: [], lngs: [] }, stats: emptyStats() }
groups.set(slug, g)
}
g.countries.push(c)
}
for (const p of posts) {
const code = p.country_code?.value
if (!code) continue
const g = groups.get(regionSlugByCountry.get(code))
addToStats(g.stats, p)
addPin(g.pins, p)
}
const out = []
for (const [slug, g] of groups) {
const view = viewFor(g.pins)
out.push({
slug,
value: g.value,
label: g.label,
countryCount: g.countries.length,
center: view.center,
zoom: view.zoom,
bounds: view.bounds,
countries: g.countries,
...g.stats,
})
}
out.sort((a, b) => a.label.localeCompare(b.label))
return out
}

View file

@ -57,6 +57,7 @@
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
"check:card-deck": "node internal/check-card-deck.mjs",
"check:poster-raster": "node internal/check-poster-raster.mjs",
"check:print-geometry": "node internal/check-print-geometry.mjs",
"check:packet-browser": "node internal/check-packet-browser.mjs",
"check:packet-render": "node internal/check-packet-render.mjs",
"check:template-literal": "node internal/template-literal-check.mjs",

View file

@ -271,9 +271,16 @@ const exportState = ref(null) // { label, phase, message, pct, kind }
// is what commercial card printers, sleeves and boxes are built around.
// @page takes these as explicit inches — a NAMED size silently falls back to letter
// in Chromium, which is the exact bug this deck is being fixed for.
// Both orientations, because the Driver asked for "horizontal cards" as well as
// vertical ones. A landscape stock is not merely transposed: the renderer moves
// the photo BESIDE the text, since a 46%-height band on a 2.5in-tall card is too
// short to carry a face. The labels come from the renderer's own STOCK table so
// this list cannot drift from what the renderer actually supports.
const DECK_STOCKS = [
{ id: 'poker', label: 'Card 2.5 × 3.5 in' },
{ id: 'pokerLandscape', label: 'Card 3.5 × 2.5 in (landscape)' },
{ id: 'postcard', label: 'Postcard 4 × 6 in' },
{ id: 'postcardLandscape', label: 'Postcard 6 × 4 in (landscape)' },
{ id: 'a6', label: 'A6 105 × 148 mm' },
]
const deckStock = ref('poker')

View file

@ -40,6 +40,7 @@ import { splitLastWord } from './src/useLastWordHighlight.js'
// order its CVD separation was validated in — the legend must render in it for that to hold.
import { PALETTE as REGION_PALETTE, WAGF_REGIONS, REGION_ORDER } from '@map/colors/wagf-region.js'
import RegionCountryMap from './src/RegionCountryMap.vue'
import { summarizeRegions, slugify, emptyStats, toNumber, PEOPLE_GROUP_LIST_FIELDS } from '@map/utils/countries-meta.js'
const props = defineProps({
profileConfig: { type: String, default: '' },
@ -118,85 +119,9 @@ const sel = ref({
// A SUPERSET of tab 1's list: name + slug are added for the map popups and the country
// people-group list. Every ROLLUP field is unchanged, so the two tabs still roll up
// identically -- the extra fields are read by the map, never by addToStats().
const FIELDS = 'name,slug,country_code,wagf_region,latitude,longitude,population,adopted_by_churches,people_committed,engagement_status'
function toNumber(v) {
const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0)
return Number.isNaN(n) ? 0 : n
}
function slugify(name) {
return String(name || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/['\u2019.]/g, '')
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
const FULL_PRAYER_COVERAGE_COUNT = 100
function emptyStats() {
return { count: 0, population: 0, adopted: 0, engaged: 0, withPrayer: 0, withFullPrayer: 0, peopleCommitted: 0 }
}
function addToStats(stats, p) {
const committed = toNumber(p.people_committed)
stats.count++
stats.population += toNumber(p.population)
if ((p.adopted_by_churches ?? 0) > 0) stats.adopted++
if (p.engagement_status?.value === 'engaged') stats.engaged++
if (committed > 0) stats.withPrayer++
if (committed >= FULL_PRAYER_COVERAGE_COUNT) stats.withFullPrayer++
stats.peopleCommitted += committed
}
function summarizeRegions(posts) {
const countryGroups = new Map()
for (const p of posts) {
const code = p.country_code?.value
if (!code) continue
let g = countryGroups.get(code)
if (!g) {
g = { name: p.country_code.label, region: { value: '', label: '' }, stats: emptyStats() }
countryGroups.set(code, g)
}
if (!g.region.value && p.wagf_region?.value) g.region = p.wagf_region
addToStats(g.stats, p)
}
const countries = []
for (const [code, g] of countryGroups) {
countries.push({ slug: slugify(g.name) || slugify(code) || code.toLowerCase(), code, name: g.name, region: g.region, ...g.stats })
}
countries.sort((a, b) => a.name.localeCompare(b.name))
const regionSlugByCountry = new Map(countries.map((c) => [c.code, slugify(c.region.value) || 'other']))
const regionGroups = new Map()
for (const c of countries) {
const slug = regionSlugByCountry.get(c.code)
let g = regionGroups.get(slug)
if (!g) {
g = { value: c.region.value, label: c.region.label, countries: [], stats: emptyStats() }
regionGroups.set(slug, g)
}
g.countries.push(c)
}
for (const p of posts) {
const code = p.country_code?.value
if (!code) continue
const g = regionGroups.get(regionSlugByCountry.get(code))
if (g) addToStats(g.stats, p)
}
const out = []
for (const [slug, g] of regionGroups) {
out.push({ slug, value: g.value, label: g.label, countryCount: g.countries.length, countries: g.countries, ...g.stats })
}
out.sort((a, b) => (a.label || 'Other').localeCompare(b.label || 'Other'))
return out
}
// Their PEOPLE_GROUP_LIST_FIELDS plus the two this tab's map needs (name, slug) — derived
// from the shared list so the two can never drift apart.
const FIELDS = 'name,slug,' + PEOPLE_GROUP_LIST_FIELDS
function formatNumber(n) {
// THEIRS, RegionDetail.vue:16: `n.toLocaleString(`${locale.value}-u-nu-latn`)` — locale

View file

@ -39,6 +39,7 @@ import { useRegionsI18n } from './src/useRegionsI18n.js'
import { RTL_LOCALES } from '@map/i18n/index.js'
import { useI18n } from 'vue-i18n'
import { useDocumentFont, BEBAS_NEUE_FACES, POPPINS_FACES } from '@map/composables/useDocumentFont.js'
import { summarizeRegions, slugify, emptyStats, toNumber, PEOPLE_GROUP_LIST_FIELDS } from '@map/utils/countries-meta.js'
const props = defineProps({
profileConfig: { type: String, default: '' },
@ -108,91 +109,15 @@ const loading = ref(true)
const loadFailed = ref(false)
// ── same field list the source page's PEOPLE_GROUP_LIST_FIELDS asks for ─────
const FIELDS = 'country_code,wagf_region,latitude,longitude,population,adopted_by_churches,people_committed,engagement_status'
function toNumber(v) {
const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0)
return Number.isNaN(n) ? 0 : n
}
function slugify(name) {
return String(name || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // strip combining diacritics
.toLowerCase()
.replace(/['\u2019.]/g, '') // drop apostrophes / periods
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
const FULL_PRAYER_COVERAGE_COUNT = 100
function emptyStats() {
return { count: 0, population: 0, adopted: 0, engaged: 0, withPrayer: 0, withFullPrayer: 0, peopleCommitted: 0 }
}
function addToStats(stats, p) {
const committed = toNumber(p.people_committed)
stats.count++
stats.population += toNumber(p.population)
if ((p.adopted_by_churches ?? 0) > 0) stats.adopted++
if (p.engagement_status?.value === 'engaged') stats.engaged++
if (committed > 0) stats.withPrayer++
if (committed >= FULL_PRAYER_COVERAGE_COUNT) stats.withFullPrayer++
stats.peopleCommitted += committed
}
// Group people-group records into one summary per country, then roll countries up into one
// summary per WAGF region — same two-pass shape as countries-meta.ts's summarizeCountries() +
// summarizeRegions(), minus the map bounds/zoom (this element has no map).
function summarizeRegions(posts) {
const countryGroups = new Map()
for (const p of posts) {
const code = p.country_code?.value
if (!code) continue
let g = countryGroups.get(code)
if (!g) {
g = { name: p.country_code.label, region: { value: '', label: '' }, stats: emptyStats() }
countryGroups.set(code, g)
}
if (!g.region.value && p.wagf_region?.value) g.region = p.wagf_region
addToStats(g.stats, p)
}
const countries = []
for (const [code, g] of countryGroups) {
countries.push({ slug: slugify(g.name) || slugify(code) || code.toLowerCase(), code, name: g.name, region: g.region, ...g.stats })
}
countries.sort((a, b) => a.name.localeCompare(b.name))
const regionSlugByCountry = new Map(countries.map((c) => [c.code, slugify(c.region.value) || 'other']))
const regionGroups = new Map()
for (const c of countries) {
const slug = regionSlugByCountry.get(c.code)
let g = regionGroups.get(slug)
if (!g) {
g = { value: c.region.value, label: c.region.label, countries: [], stats: emptyStats() }
regionGroups.set(slug, g)
}
g.countries.push(c)
}
for (const p of posts) {
const code = p.country_code?.value
if (!code) continue
const g = regionGroups.get(regionSlugByCountry.get(code))
if (g) addToStats(g.stats, p)
}
const out = []
for (const [slug, g] of regionGroups) {
out.push({ slug, value: g.value, label: g.label, countryCount: g.countries.length, countries: g.countries, ...g.stats })
}
out.sort((a, b) => (a.label || 'Other').localeCompare(b.label || 'Other'))
return out
}
// Their PEOPLE_GROUP_LIST_FIELDS, imported rather than restated — one list, one source.
const FIELDS = PEOPLE_GROUP_LIST_FIELDS
function formatNumber(n) {
return Number(n || 0).toLocaleString('en-US')
// Theirs, RegionDetail.vue:16: n.toLocaleString(`${locale}-u-nu-latn`) — locale grouping
// separators with Latin digits. Hardcoding en-US made de/fr/es wrong (they group with '.'
// or a thin space). Same fix already applied in region-dashboards.vue.
const v = Number(n || 0)
try { return v.toLocaleString(`${i18nLocale.value || 'en'}-u-nu-latn`) } catch (_) { return v.toLocaleString('en-US') }
}
function summaryLine(region) {

View file

@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/devanagari)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,24 @@
# Noto Serif Devanagari — the Devanagari subset only
**Why vendored:** the prayer packet's font stack is Georgia / Palatino Linotype /
serif, none of which carry Devanagari. Measured by rendering Devanagari at 48px
and READING THE PIXELS, Hindi came out as **tofu boxes** — this machine has zero
Devanagari fonts (`fc-list`). A `hi` translation without this file would ship a
page of empty rectangles.
**Licence:** SIL Open Font License 1.1 (see `LICENSE`). Free to bundle and serve.
**Subset:** `U+0900-097F` plus the Devanagari extensions Google's own subsetting
selects — the *devanagari* range ONLY, matching how `poppins/` ships `latin` and
`latin-ext` separately so a page downloads only what it needs. 124 KB, against
6-8 KB for a Latin subset; that is the cost of the script's glyph count, and it
loads only on a Hindi packet.
**Source:** the woff2 Google Fonts serves for
`Noto+Serif+Devanagari:wght@400;700` with a modern browser UA. Note the 400 and
700 faces resolve to the SAME file — it is a variable font, so one file covers
both weights and vendoring two would duplicate 124 KB for nothing.
To refresh: request that CSS with a Chrome user-agent (without one Google serves
unsubsetted TTF), take the `@font-face` block whose `unicode-range` contains
`U+0900`, and download its woff2.

View file

@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/devanagari)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,24 @@
# Noto Serif Devanagari — the Devanagari subset only
**Why vendored:** the prayer packet's font stack is Georgia / Palatino Linotype /
serif, none of which carry Devanagari. Measured by rendering Devanagari at 48px
and READING THE PIXELS, Hindi came out as **tofu boxes** — this machine has zero
Devanagari fonts (`fc-list`). A `hi` translation without this file would ship a
page of empty rectangles.
**Licence:** SIL Open Font License 1.1 (see `LICENSE`). Free to bundle and serve.
**Subset:** `U+0900-097F` plus the Devanagari extensions Google's own subsetting
selects — the *devanagari* range ONLY, matching how `poppins/` ships `latin` and
`latin-ext` separately so a page downloads only what it needs. 124 KB, against
6-8 KB for a Latin subset; that is the cost of the script's glyph count, and it
loads only on a Hindi packet.
**Source:** the woff2 Google Fonts serves for
`Noto+Serif+Devanagari:wght@400;700` with a modern browser UA. Note the 400 and
700 faces resolve to the SAME file — it is a variable font, so one file covers
both weights and vendoring two would duplicate 124 KB for nothing.
To refresh: request that CSS with a Chrome user-agent (without one Google serves
unsubsetted TTF), take the `@font-face` block whose `unicode-range` contains
`U+0900`, and download its woff2.

File diff suppressed because one or more lines are too long

View file

@ -640,7 +640,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "doxa-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-Q0oRTPiR5rCbGuYjqx06LU6ngVOJflT7LZIevgE0eGypVGomWEPkzJcpmSaEEOW6",
"integrity": "sha384-cs9Yz6dE0cp2ChVeNB9btNOXrqNk+xl0r4374AkMHRiEW7x+u3wlLDBv9TU469Ra",
"elements": [
"my-upg-100-list-map"
],
@ -660,7 +660,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-Q0oRTPiR5rCbGuYjqx06LU6ngVOJflT7LZIevgE0eGypVGomWEPkzJcpmSaEEOW6\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-cs9Yz6dE0cp2ChVeNB9btNOXrqNk+xl0r4374AkMHRiEW7x+u3wlLDBv9TU469Ra\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -674,7 +674,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-Q0oRTPiR5rCbGuYjqx06LU6ngVOJflT7LZIevgE0eGypVGomWEPkzJcpmSaEEOW6\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-cs9Yz6dE0cp2ChVeNB9btNOXrqNk+xl0r4374AkMHRiEW7x+u3wlLDBv9TU469Ra\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -688,7 +688,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-Q0oRTPiR5rCbGuYjqx06LU6ngVOJflT7LZIevgE0eGypVGomWEPkzJcpmSaEEOW6\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-cs9Yz6dE0cp2ChVeNB9btNOXrqNk+xl0r4374AkMHRiEW7x+u3wlLDBv9TU469Ra\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]
@ -838,7 +838,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-G5kRzcJ2eKbMi2QatgYKKtrwZOo133Mr0ymaMwFJ/i6PWsCUq71lSXNnAeBwnWoN",
"integrity": "sha384-CFvBQbLzGcJKwekfNfjZoR+LtYaMBsbDLaumMmvyKWdtDMN8Eer5XSMzYLHrQy8w",
"elements": [
"regions-dashboard"
],
@ -858,7 +858,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-G5kRzcJ2eKbMi2QatgYKKtrwZOo133Mr0ymaMwFJ/i6PWsCUq71lSXNnAeBwnWoN\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-CFvBQbLzGcJKwekfNfjZoR+LtYaMBsbDLaumMmvyKWdtDMN8Eer5XSMzYLHrQy8w\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -872,7 +872,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-G5kRzcJ2eKbMi2QatgYKKtrwZOo133Mr0ymaMwFJ/i6PWsCUq71lSXNnAeBwnWoN\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-CFvBQbLzGcJKwekfNfjZoR+LtYaMBsbDLaumMmvyKWdtDMN8Eer5XSMzYLHrQy8w\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -886,7 +886,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-G5kRzcJ2eKbMi2QatgYKKtrwZOo133Mr0ymaMwFJ/i6PWsCUq71lSXNnAeBwnWoN\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-CFvBQbLzGcJKwekfNfjZoR+LtYaMBsbDLaumMmvyKWdtDMN8Eer5XSMzYLHrQy8w\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-G5kRzcJ2eKbMi2QatgYKKtrwZOo133Mr0ymaMwFJ/i6PWsCUq71lSXNnAeBwnWoN" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-CFvBQbLzGcJKwekfNfjZoR+LtYaMBsbDLaumMmvyKWdtDMN8Eer5XSMzYLHrQy8w" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-G5kRzcJ2eKbMi2QatgYKKtrwZOo133Mr0ymaMwFJ/i6PWsCUq71lSXNnAeBwnWoN" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-CFvBQbLzGcJKwekfNfjZoR+LtYaMBsbDLaumMmvyKWdtDMN8Eer5XSMzYLHrQy8w" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-G5kRzcJ2eKbMi2QatgYKKtrwZOo133Mr0ymaMwFJ/i6PWsCUq71lSXNnAeBwnWoN" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-CFvBQbLzGcJKwekfNfjZoR+LtYaMBsbDLaumMmvyKWdtDMN8Eer5XSMzYLHrQy8w" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: