Integrate main into bundler-2/work
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

# Conflicts:
#	public/doxa-maps/index.html
This commit is contained in:
doxa-maps-88655c70 2026-09-25 12:21:20 +00:00
commit d78a42f2df
74 changed files with 4509 additions and 121 deletions

View file

@ -73,6 +73,20 @@ the Research Gallery.
<sub>✅ works today · 🧪 proven, not yet in production. Every plugin builds into the same folder, and that folder is what all five methods ship — so a capability never limits where a plugin can go.</sub>
### The Experimental shelf
**New plugins the Research Gallery picks up by itself** — each builds and embeds like any other,
and graduates to a named tab when it's ready.
<table>
<tr>
<td width="50%" valign="top"><img src="documentation/readme-images/exp-wall-of-people-groups.png" alt="Wall of People Groups"><br><b>Wall of People Groups</b><br><sub>all 2,107 Doxa people groups as photo cards on one zoomable prayer wall, with a pray dialog</sub></td>
<td width="50%" valign="top"><img src="documentation/readme-images/exp-prayer-walk-planner.png" alt="Prayer Walk Planner"><br><b>Prayer Walk Planner</b><br><sub>plan a prayer walk through a zone, open it full screen from any embed, and print a walking map that stays sharp when zoomed</sub></td>
</tr>
</table>
<sub>Also on the shelf: UPG neighbor, all people groups, country posters, the vision card, and the PPLR data maps.</sub>
<details>
<summary><b>How one build becomes five ways to ship — the "map cruncher"</b></summary>
@ -149,7 +163,9 @@ Research Gallery <doxa-maps-spa> capability 5
├─ UPG neighbor upg-neighbor capability 3
├─ all people groups all-people-groups capability 1
├─ country posters country-posters capability 4
└─ vision card doxa-vision-card capability 2
├─ vision card doxa-vision-card capability 2
├─ PPLR data maps pplr-data-maps capability 3
└─ Wall of People Groups wall-of-people-groups capability 3
```
</details>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 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.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 891 KiB

After

Width:  |  Height:  |  Size: 891 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 758 KiB

After

Width:  |  Height:  |  Size: 751 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View file

@ -1,23 +1,23 @@
{
"cap1-single-element": {
"bundle": "all-people-groups",
"sha256": "0ede35b98a92ea2a56fa5b6302eb3dd827d9de4c636f82f663f9c0f4d8b24890"
"sha256": "c990929277d9aa5fd0d4ef78362f7b43e328a7c073ac6bf97aadfde5cb942de9"
},
"cap2-adoption": {
"bundle": "doxa-simple-map",
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
},
"cap2-engagement": {
"bundle": "doxa-simple-map",
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
},
"cap2-prayer": {
"bundle": "doxa-simple-map",
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
},
"cap3-monolithic-research": {
"bundle": "doxa-research-map",
"sha256": "3391075b32dd710b7f810e3097074a4d453c7720a6963e101ddec99ad4186d29"
"sha256": "b849fb71c4a96810d840d9f2dfc208c3e13648429e0ae1a1c5784693e6e5774f"
},
"cap4-upg100-list": {
"bundle": "my-upg-100-list",
@ -43,17 +43,25 @@
"bundle": "my-upg-100-list",
"sha256": "fab621d68bdad964da2be67135d0385d40417c6180f008f36cbc0f50076f5e4a"
},
"exp-prayer-walk-planner": {
"bundle": "prayer-walk-planner",
"sha256": "cee5f2806bd5cc8a7fa4c49db200df643d07cf303fd75ee739e606dd9ee93aee"
},
"exp-wall-of-people-groups": {
"bundle": "wall-of-people-groups",
"sha256": "ba718e082f77f50d5775ebf43007385b7293f8099e2ca4d79e8ad1753ac4133f"
},
"prod-big-picture": {
"bundle": "dashboard-plus-grid",
"sha256": "d3eb3468e6f42c3346885a665102dd10965a98a5e045f50b0d15c14f52a05e56"
"sha256": "e90739f622678db4c421c76855a5c39882aabd4ed8dbdd65cbe1385a45c67edc"
},
"prod-cities-internet": {
"bundle": "cities-internet",
"sha256": "1372e30e723497c6164c409b2e41e095f5f18f38caba606edb4ca58293640d39"
"sha256": "ad145b5a63e7d4b7468a54db53f2f71f397216956fec698d357ae5b841fa8e3b"
},
"prod-cities-internet-phone": {
"bundle": "cities-internet",
"sha256": "1372e30e723497c6164c409b2e41e095f5f18f38caba606edb4ca58293640d39"
"sha256": "ad145b5a63e7d4b7468a54db53f2f71f397216956fec698d357ae5b841fa8e3b"
},
"prod-gospel-resources": {
"bundle": "gospel-resources",
@ -65,15 +73,15 @@
},
"prod-prayer-phone": {
"bundle": "doxa-simple-map",
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
},
"prod-regions": {
"bundle": "regions-dashboard",
"sha256": "a9b91cd0428834cdbdc711f5be2921c08fc2f2b057081f26c21812e56cfa114e"
"sha256": "8420509dde0ea1390e0d4ae8d49f80a303d17fa8a38c37755a82b07ab09567f5"
},
"prod-regions-phone": {
"bundle": "regions-dashboard",
"sha256": "a9b91cd0428834cdbdc711f5be2921c08fc2f2b057081f26c21812e56cfa114e"
"sha256": "8420509dde0ea1390e0d4ae8d49f80a303d17fa8a38c37755a82b07ab09567f5"
},
"prod-research-gallery-phone": {
"bundle": "doxa-maps-spa",
@ -81,10 +89,10 @@
},
"prod-simple-research": {
"bundle": "research-map-plus-grid",
"sha256": "458139bb4b1da0e79fefeb3c671b78d81da4ef832e411e54789915ca07007ee7"
"sha256": "9e081907bd697b5ac63531cedbdb34d3f47a09d1d5aee3634722128af1136d0f"
},
"prod-simple-research-phone": {
"bundle": "research-map-plus-grid",
"sha256": "458139bb4b1da0e79fefeb3c671b78d81da4ef832e411e54789915ca07007ee7"
"sha256": "9e081907bd697b5ac63531cedbdb34d3f47a09d1d5aee3634722128af1136d0f"
}
}

View file

@ -168,3 +168,42 @@ export function requirePathInThisCheckout(file, repo, label) {
console.log(`${label}: note — ${file} is outside any checkout, so which build produced it cannot be checked.`)
return { ok: true, why: 'outside any checkout' }
}
/**
* The same guard for gates aimed at a VITE DEV SERVER, which has no built manifest to fingerprint.
*
* A dev server serves this repo's SOURCE, so identity is the source itself: ask it for a few of this
* checkout's library files with Vite's `?raw` query (the file's exact text, as an ES module string)
* and compare byte for byte. A dev server rooted in another worktree with different code answers
* differently, or 403s the path — either way it is NOT MEASURED (exit 75). One serving identical code
* is, by definition, measuring the same thing. Unreachable is left to the gate's own handling.
* Remote (non-loopback) targets are not checked, as in requireThisTree.
*/
const DEV_FINGERPRINT = ['library/layouts/VerticalTabsLayout.vue', 'library/composables/useMapInstance.js', 'library/composables/useShadowStyles.js']
export async function requireDevServesThisCheckout(base, repo, label, files = DEV_FINGERPRINT) {
let origin
try { origin = new URL(String(base)).origin } catch { return { ok: true, why: 'not a URL' } }
if (!/^(localhost|127\.|0\.0\.0\.0$|\[?::1\]?$)/.test(new URL(origin).hostname)) return { ok: true, why: 'remote target' }
const refuse = (why) => {
console.log(`${label}: NOT MEASURED — the dev server at ${origin} is not serving THIS checkout's source (${why}).`)
console.log(' Start `bun x vite --port <yours> --strictPort` in this worktree and pass that URL.')
process.exit(75)
}
for (const rel of files) {
let local
try { local = readFileSync(resolve(repo, rel), 'utf8') } catch { continue }
const r = await fetch(`${origin}/${rel}?raw`, { signal: AbortSignal.timeout(8000) }).catch(() => null)
if (!r) return { ok: false, why: 'unreachable' }
if (!r.ok) refuse(`${rel} → HTTP ${r.status}`)
// A plain fetch (not a module import) gets the file's TEXT served directly; an import-style
// request gets it wrapped as `export default "…"`. Accept both shapes — the first version only
// parsed the wrapped one and refused this very checkout's own dev server (measured 2026-09-25).
const body = await r.text()
const m = body.match(/^export default ("(?:[^"\\]|\\.)*")/s)
let served = body
if (m) { try { served = JSON.parse(m[1]) } catch { served = null } }
if (served !== local) refuse(`${rel} differs`)
}
return { ok: true, why: 'same source' }
}

View file

@ -52,9 +52,16 @@ done
# Fast path: any free slot, in a FIXED order so two processes can never take them in opposite
# orders. -n means "do not wait": if this slot is busy we move on rather than queue behind it.
#
# ★ `{ exec 9>…; } 2>/dev/null`, NEVER `exec 9>… 2>/dev/null`. A bare exec applies EVERY redirection
# on its line to this shell for good, so the old form sent stderr to /dev/null for the rest of the
# script AND for the gate exec'd below. Measured 2026-09-25: every gate run through this wrapper lost
# its stderr — verify-map-paint exited 1 on two leaves with its FAIL lines (console.error) gone, and
# this script's own "not measuring" message vanished too. The braces scope the 2>/dev/null to the
# open alone; fd 9 still persists, which is the only thing the exec is for.
i=1
while [ "$i" -le "$SLOTS" ]; do
exec 9>"$(slot_path "$i")" 2>/dev/null || true
{ exec 9>"$(slot_path "$i")"; } 2>/dev/null || true
if flock -n 9; then
exec "$@"
fi
@ -72,7 +79,7 @@ deadline=$(( $(date +%s) + WAIT_S ))
while [ "$(date +%s)" -lt "$deadline" ]; do
i=1
while [ "$i" -le "$SLOTS" ]; do
exec 9>"$(slot_path "$i")" 2>/dev/null || true
{ exec 9>"$(slot_path "$i")"; } 2>/dev/null || true
if flock -n 9; then exec "$@"; fi
i=$((i + 1))
done

View file

@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* check-basemap-labels-localized — the BASEMAP's place names follow the reader's language, on BOTH
* engines (card BUILD-LOCALIZATION-SURVIVES-THE-PROVIDER-SWAP; the code under test is
* library/maps/localize-labels.js, 319064a1 / 4839f879).
*
* USAGE: node internal/check-basemap-labels-localized.mjs [--dir <tree root>] [--leaf bundle/profile]
* Serves THIS checkout's public/ on an OS-assigned port (so it cannot measure another tree),
* or --dir (used for the negative control: a tree whose bundle predates the fix).
* Exit 0 = both engines localize · 1 = an engine does not · 2 = could not measure.
*
* WHAT IS READ — the RENDERED label, not our own guess at an expression:
* Mapbox Standard keeps its labels in the IMPORTED basemap, invisible to a plain
* queryRenderedFeatures. The 'place-labels' featureset of the 'basemap' import returns
* each country label with the `name` the engine drew (setLanguage localizes it).
* MapLibre queryRenderedFeatures returns each symbol's EVALUATED text-field, i.e. the text drawn.
* WHAT IS ASSERTED, per engine, between ?lang=en and ?lang=ru on the same view (30°E 45°N, z3):
* Mapbox known countries flip: Germany→Германия, Ukraine→Украина, Turkey→Турция, Egypt→Египет
* (at least two of them seen in both runs).
* MapLibre for the country labels (identified by their name:en property), the ru run draws the
* feature's name:ru, and the en run does not. (English on MapLibre restores the style's
* ORIGINAL label, OSM's Latin name — 'Deutschland', not 'Germany' — so no English word
* is expected there; the flip to Cyrillic is the evidence.)
*
* NEGATIVE-TESTED 2026-09-25: the same leaf built from 319064a1's parent (before localize-labels
* existed), served with --dir, FAILS on both engines.
*/
import { chromium } from 'playwright'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
const LEAF = arg('--leaf', 'doxa-simple-map/doxa-simple-map')
const PAIRS = { Germany: 'Германия', Ukraine: 'Украина', Turkey: 'Турция', Egypt: 'Египет', Kazakhstan: 'Казахстан', Poland: 'Польша' }
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' })
await new Promise((r) => setTimeout(r, 900))
const up = await fetch(`http://127.0.0.1:${port}/doxa-maps/${LEAF}/index.html`).then((r) => r.ok).catch(() => false)
if (!up) { srv.kill(); console.log(`BASEMAP-LABELS: NOT MEASURED — could not serve ${DIR}`); process.exit(2) }
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
async function labels(provider, lang) {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
await page.goto(`http://127.0.0.1:${port}/doxa-maps/${LEAF}/index.html?lang=${lang}${provider === 'maplibre' ? '&provider=maplibre' : ''}`, { waitUntil: 'networkidle' })
const FIND = `(() => { const f = (r) => { for (const e of r.querySelectorAll('*')) { if (e.__doxaMap) return e.__doxaMap; if (e.shadowRoot) { const m = f(e.shadowRoot); if (m) return m } } return null }; return f(document) })()`
let ok = false
for (let i = 0; i < 60 && !ok; i++) { await page.waitForTimeout(500); ok = await page.evaluate(`(() => { const m = ${FIND}; return !!(m && m.isStyleLoaded && m.isStyleLoaded()) })()`).catch(() => false) }
if (!ok) { await page.close(); return null }
await page.evaluate(`(() => { const m = ${FIND}; m.jumpTo({ center: [30, 45], zoom: 3 }) })()`)
await page.waitForTimeout(7000)
const out = await page.evaluate(`(() => { const m = ${FIND}
if (${JSON.stringify(provider)} === 'mapbox') {
let fs = []; try { fs = m.queryRenderedFeatures({ target: { featuresetId: 'place-labels', importId: 'basemap' } }) } catch (e) { return { error: e.message } }
return { names: fs.filter((f) => f.properties && f.properties.class === 'country').map((f) => f.properties.name) }
}
const text = (tf) => (tf && Array.isArray(tf.sections)) ? tf.sections.map((s) => s.text).join('') : (typeof tf === 'string' ? tf : '')
const feats = m.queryRenderedFeatures().filter((f) => f.layer && f.layer.type === 'symbol' && /country/.test(f.layer.id) && f.properties['name:en'])
return { countries: feats.map((f) => ({ en: f.properties['name:en'], ru: f.properties['name:ru'] || '', drawn: text(f.layer.layout && f.layer.layout['text-field']) })) }
})()`)
await page.close()
return out
}
const results = []
for (const provider of ['mapbox', 'maplibre']) {
const en = await labels(provider, 'en'), ru = await labels(provider, 'ru')
if (!en || !ru || en.error || ru.error) { results.push({ provider, measured: false, why: (en && en.error) || (ru && ru.error) || 'map never finished its style' }); continue }
if (provider === 'mapbox') {
const seen = Object.keys(PAIRS).filter((k) => en.names.includes(k))
const flipped = seen.filter((k) => ru.names.includes(PAIRS[k]))
// MEASURED = the engine drew enough country labels to judge. Not "saw two known English names":
// without localization Mapbox Standard draws a different default form, and that absence IS the
// defect — the first version of this line reported the pre-fix build as NOT MEASURED instead of
// FAIL (68 country labels drawn, none English), which would let the gate pass by abstaining.
results.push({ provider, measured: en.names.length >= 5 && ru.names.length >= 5, ok: seen.length >= 2 && flipped.length === seen.length,
detail: `en saw ${seen.join(', ') || '(none)'}; ru drew ${flipped.map((k) => PAIRS[k]).join(', ') || '(none of them)'} (${en.names.length}/${ru.names.length} country labels)` })
} else {
const byEn = (arr) => new Map(arr.map((c) => [c.en, c]))
const E = byEn(en.countries), R = byEn(ru.countries)
const common = [...R.keys()].filter((k) => E.has(k) && R.get(k).ru)
const localized = common.filter((k) => R.get(k).drawn === R.get(k).ru && E.get(k).drawn !== R.get(k).ru)
results.push({ provider, measured: common.length >= 2, ok: common.length >= 2 && localized.length === common.length,
detail: `${localized.length}/${common.length} countries drawn in Russian under ru and not under en` + (common[0] ? ` — e.g. ${common[0]}: en "${E.get(common[0]).drawn}" → ru "${R.get(common[0]).drawn}"` : '') })
}
}
await browser.close(); srv.kill()
for (const r of results) console.log(` ${r.provider.padEnd(9)} ${!r.measured ? 'NOT MEASURED' : r.ok ? 'ok ' : 'FAIL'} ${r.detail || r.why}`)
if (results.some((r) => r.measured && !r.ok)) { console.log('BASEMAP-LABELS: FAIL — a basemap does not follow the reader\'s language on every engine.'); process.exit(1) }
if (results.some((r) => !r.measured)) { console.log('BASEMAP-LABELS: NOT MEASURED — an engine gave too few labels to judge.'); process.exit(2) }
console.log(`BASEMAP-LABELS: PASS — ${LEAF}'s basemap place names follow the reader's language on Mapbox AND MapLibre.`)

View file

@ -0,0 +1,185 @@
#!/usr/bin/env node
/**
* check-experimental-shelf — "all experimental plugins working", as a measurement.
*
* THE SHELF is derived, never listed: every bundle in the built manifest that the Research Gallery
* would discover into its Experimental folder, i.e. not a curated tab and not in NOT_EXPERIMENTAL.
* Both sets are read from plugin-designs/doxa-maps-spa/doxa-maps-spa.vue, so a bundle that joins or
* leaves the shelf changes what this gate measures without an edit here.
*
* PER BUNDLE, PER ENGINE (mapbox, maplibre), on TWO surfaces: its own leaf page (A) and its tab
* inside the Research Gallery (D). A surface passes when:
* - the element mounted with content (its shadow tree has real nodes),
* - 0 page errors and 0 failed requests (4xx/5xx; Mapbox telemetry and favicons excluded),
* - no loading text is left on screen (Loading / Adding / Computing),
* - and, for every MAP on the page: its 'load' fired, and it DRAWS its own data, meaning at
* least one rendered feature on a layer fed by a GeoJSON source. That is the lesson from the
* PPLR check: 'load' alone is not loaded, and an empty GeoJSON source still counts as a
* source.
* Maps are found without touching any bundle: an init script wraps the engine's Map class as the
* engine script defines it, so every map the page constructs is recorded.
*
* IDENTITY: the gate serves THIS checkout's public/ itself, on a port the OS hands out, so it
* cannot grade another worktree's tree (the fixed-port trap internal/_serves-this-tree.mjs names).
*
* SELF-TEST (--self-test, and always first unless --no-self-test): the first shelf bundle's script
* is replaced in flight by one that throws. That bundle MUST fail on both surfaces; if it does not,
* the gate is blind and exits 3 before grading anything.
*
* Usage: node internal/check-experimental-shelf.mjs [--engines mapbox,maplibre] [--only a,b]
* [--no-self-test] [--self-test]
* Exit: 0 all pass · 1 a bundle failed · 3 self-test could not see a broken bundle · 2 setup.
* Run browser gates through internal/browser-slot.sh.
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const arg = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const ENGINES = arg('--engines', 'mapbox,maplibre').split(',')
const ONLY = arg('--only', '') ? arg('--only', '').split(',') : null
const SELF_TEST = !argv.includes('--no-self-test')
const WAIT_MS = Number(process.env.SHELF_WAIT_MS || 75000)
const { chromium } = await import(resolve(ROOT, 'node_modules/playwright/index.mjs'))
const manifest = JSON.parse(readFileSync(resolve(ROOT, 'public/doxa-maps/manifest.json'), 'utf8'))
const spa = readFileSync(resolve(ROOT, 'plugin-designs/doxa-maps-spa/doxa-maps-spa.vue'), 'utf8')
const cfg = JSON.parse(readFileSync(resolve(ROOT, 'vite.doxa-maps-build-config.json'), 'utf8'))
const TK = process.env.MAPBOX_TOKEN || cfg.token || ''
// ── the shelf, derived from the Gallery's own rules ───────────────────────────
const notExp = new Set((spa.match(/const NOT_EXPERIMENTAL = new Set\(\[([\s\S]*?)\]\)/) || [, ''])[1].match(/'([a-z0-9-]+)'/g)?.map(s => s.slice(1, -1)) || [])
const curated = new Set([...spa.matchAll(/bundle:\s*'([a-z0-9-]+)'/g)].map(m => m[1]))
if (!notExp.size || !curated.size) { console.error('check-experimental-shelf: could not read NOT_EXPERIMENTAL / curated tabs from doxa-maps-spa.vue'); process.exit(2) }
const shelf = manifest.bundles
.filter(b => !notExp.has(b.bundle) && !curated.has(b.bundle) && (b.profiles || []).length)
.filter(b => !ONLY || ONLY.includes(b.bundle))
.map(b => {
// the entry profile the Gallery opens: a map profile, else the one named like the bundle, else the first
const p = b.profiles.find(x => x.isMap) || b.profiles.find(x => x.profile === b.bundle) || b.profiles[0]
return { bundle: b.bundle, profile: p.profile, isMap: !!p.isMap, tag: p.element || (b.elements || [])[0] || b.bundle }
})
if (!shelf.length) { console.error('check-experimental-shelf: the shelf is empty'); process.exit(2) }
// ── SEEDS: bundles whose data is the USER'S, so a fresh browser correctly shows none ─────────
// Seeded before load, then required to be DRAWN, which is stronger than exempting them. Printed on
// every run with the reason, so a seed can never quietly hide a regression.
const SEED = {
'prayer-walk-planner': {
why: 'walk zones are user-drawn and kept in localStorage; a fresh browser has none, so one known zone is seeded and must be drawn',
keyPrefix: 'prayer-walk-planner:v1:',
value: JSON.stringify([{ id: 'shelf-seed', title: 'Shelf check zone', coords: [[0, 10], [20, 10], [20, 25], [0, 25]] }]),
},
}
// ── our own server, on a port nobody else can be holding ──────────────────────
const port = await new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', 'public'], { cwd: ROOT, stdio: 'ignore' })
const reap = () => { try { srv.kill() } catch (_) {} }
process.on('exit', reap); for (const s of ['SIGINT', 'SIGTERM']) process.on(s, () => { reap(); process.exit(130) })
const BASE = `http://127.0.0.1:${port}/doxa-maps`
for (let i = 0; i < 40; i++) { try { if ((await fetch(BASE + '/manifest.json')).ok) break } catch (_) {} await new Promise(r => setTimeout(r, 250)) }
// ── every Map the page builds, recorded without touching the bundle ───────────
const RECORD_MAPS = () => {
window.__shelfMaps = []
for (const name of ['mapboxgl', 'maplibregl']) {
let v
Object.defineProperty(window, name, {
configurable: true,
get() { return v },
set(val) {
if (val && val.Map && !val.Map.__shelfWrapped) {
const Orig = val.Map
class Recorded extends Orig { constructor(...a) { super(...a); window.__shelfMaps.push(this); this.once('load', () => { this.__shelfLoaded = true }) } }
Recorded.__shelfWrapped = true
try { val.Map = Recorded } catch (_) { try { Object.defineProperty(val, 'Map', { value: Recorded }) } catch (_) {} }
}
v = val
},
})
}
}
const PROBE = (tag) => `(() => {
const acc = []; const w = (r) => { for (const el of r.querySelectorAll('*')) { acc.push(el); if (el.shadowRoot) w(el.shadowRoot) } }; w(document)
// CONTENT is the plugin's own subtree, not the page: a small card on a bare leaf and a card inside the
// Gallery's chrome must be judged the same way.
const host = acc.find(e => e.tagName === ${JSON.stringify('__TAG__')}.toUpperCase())
let own = 0, sized = false
if (host) { const inner = []; const w2 = (r) => { for (const el of r.querySelectorAll('*')) { inner.push(el); if (el.shadowRoot) w2(el.shadowRoot) } }; w2(host.shadowRoot || host); own = inner.length
const hr = host.getBoundingClientRect(); sized = hr.width > 100 && hr.height > 100 }
const busy = acc.filter(e => e.children.length === 0 && /Loading|Adding|Computing/i.test(e.textContent || '') && e.getBoundingClientRect().height > 0).length
const maps = (window.__shelfMaps || []).filter(m => { try { return m.getContainer() && m.getContainer().isConnected } catch (_) { return false } }).map(m => {
let drawn = 0, geo = 0
try {
const st = m.getStyle(); const srcs = st.sources || {}
const geoIds = Object.keys(srcs).filter(k => srcs[k].type === 'geojson'); geo = geoIds.length
const layers = (st.layers || []).filter(l => geoIds.includes(l.source)).map(l => l.id)
if (layers.length) drawn = m.queryRenderedFeatures({ layers }).length
} catch (_) {}
return { loaded: !!(m.__shelfLoaded || (m.isStyleLoaded && m.isStyleLoaded())), geo, drawn }
})
return { own, sized, host: !!host, busy, maps }
})()`.replace('__TAG__', tag)
const b = await chromium.launch({ headless: true, args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--enable-webgl', '--no-sandbox'] })
async function measure(url, s, poisonBundle) {
const p = await b.newPage({ viewport: { width: 1280, height: 800 } })
await p.addInitScript(RECORD_MAPS)
const seed = SEED[s.bundle]
if (seed) await p.addInitScript(({ keyPrefix, value }) => {
const get = Storage.prototype.getItem
Storage.prototype.getItem = function (k) { const v = get.call(this, k); return (v == null && String(k).startsWith(keyPrefix)) ? value : v }
}, seed)
if (poisonBundle) await p.route(new RegExp(`/${poisonBundle}/${poisonBundle}\\.js(\\?|$)`), r => r.fulfill({ status: 200, contentType: 'text/javascript', body: 'throw new Error("shelf self-test: deliberately broken bundle")' }))
const errs = [], fails = []
p.on('pageerror', e => errs.push(e.message.slice(0, 140)))
p.on('response', r => { if (r.status() >= 400 && !/events\.mapbox\.com|favicon/.test(r.url())) fails.push(r.status() + ' ' + r.url().replace(/\?.*/, '').slice(-80)) })
await p.goto(url)
let st = null
const t0 = Date.now()
while (Date.now() - t0 < WAIT_MS) {
await p.waitForTimeout(2500)
st = await p.evaluate(PROBE(s.tag))
const mapsReady = st.maps.every(m => m.loaded && m.drawn > 0)
if (st.own > 8 && st.sized && st.busy === 0 && mapsReady && Date.now() - t0 > 6000) break
}
await p.close()
const why = []
if (!st || !st.host) why.push(`<${s.tag}> never appeared`)
else if (st.own <= 8 || !st.sized) why.push(`<${s.tag}> mounted empty (${st.own} node(s)${st.sized ? '' : ', not sized'})`)
if (st && st.busy) why.push(`${st.busy} loading label(s) left`)
if (st) st.maps.forEach((m, i) => { if (!m.loaded) why.push(`map ${i + 1}: 'load' never fired`); else if (!m.drawn) why.push(`map ${i + 1}: no own data drawn (${m.geo} geojson source(s))`) })
if (errs.length) why.push(`page error: ${errs[0]}`)
if (fails.length) why.push(`failed request: ${fails[0]}`)
return { pass: !why.length, why, maps: st ? st.maps.length : 0 }
}
const leaf = (s, eng) => `${BASE}/${s.bundle}/${s.profile}/index.html?provider=${eng}` + (eng === 'mapbox' ? `&tk=${TK}` : '')
const gallery = (s, eng) => `${BASE}/doxa-maps-spa/doxa-maps-spa/index.html?tab=${s.bundle}&provider=${eng}` + (eng === 'mapbox' ? `&tk=${TK}` : '')
let exit = 0
try {
if (SELF_TEST) {
const s = shelf[0]
const a = await measure(leaf(s, 'maplibre'), s, s.bundle), d = await measure(gallery(s, 'maplibre'), s, s.bundle)
if (a.pass || d.pass) { console.error(`SELF-TEST FAILED: a deliberately broken ${s.bundle} still passed (${a.pass ? 'leaf' : ''} ${d.pass ? 'gallery' : ''}). This gate cannot see a broken bundle.`); exit = 3 }
else console.log(`self-test: a deliberately broken ${s.bundle} fails as it must (leaf: ${a.why[0]}; gallery: ${d.why[0]})`)
}
if (exit !== 3) {
for (const [k, v] of Object.entries(SEED)) if (shelf.some(s => s.bundle === k)) console.log(`SEEDED ${k}: ${v.why}`)
console.log(`EXPERIMENTAL SHELF — ${shelf.length} bundle(s): ${shelf.map(s => s.bundle).join(', ')} · engines: ${ENGINES.join(', ')}`)
for (const s of shelf) for (const eng of ENGINES) for (const [surf, url] of [['A leaf', leaf(s, eng)], ['D gallery', gallery(s, eng)]]) {
const r = await measure(url, s)
if (!r.pass) exit = 1
console.log(`${r.pass ? 'PASS' : 'FAIL'} ${s.bundle.padEnd(24)} ${eng.padEnd(9)} ${surf.padEnd(10)} ${r.maps ? r.maps + ' map(s)' : 'no map'}${r.pass ? '' : ' — ' + r.why.join('; ')}`)
}
console.log(exit ? 'EXPERIMENTAL-SHELF: FAIL' : `EXPERIMENTAL-SHELF: PASS — every experimental plugin loads on its own page and in the Gallery, under ${ENGINES.join(' and ')}`)
}
} finally { await b.close(); reap() }
process.exit(exit)

View file

@ -0,0 +1,114 @@
/**
* check-gospel-english-leaks.mjs — a translated key must not render in English.
*
* THE METHOD, from rega by way of dx-shepherd. A string is a LEAK when the catalogue has a
* DIFFERENT value for the locale and the page still shows the English one. That is a render
* or wiring fault, and it is a different thing from an untranslated string, which is a
* catalogue gap. Only the first is this check's business.
*
* WHAT IT DOES NOT COUNT, so the number means something:
* - third-party strings, e.g. the Mapbox and geoBoundaries attributions, which are licence
* obligations and must never be translated;
* - DATA, the people-group and place names that come from the publishers;
* - PLACEHOLDER-ONLY strings;
* - the explanatory PROSE this bundle deliberately keeps in English, which is declared by
* being absent from the catalogue entirely — if there is no translated value, there is
* nothing to leak.
*
* SCOPE: THE LEAF, AND THAT BOUNDARY IS THE POINT. Six keys were reported leaking in the
* gallery. Measured here, the standalone leaf is clean in ar, zh, ru and hi — and the
* gallery's iframe src is built without a lang parameter, so the embedded leaf never learns
* the locale and correctly falls back to English. That is an embedder defect, not a bundle
* one, and a check that conflated them would have sent someone to translate strings that are
* already translated.
*/
import { chromium } from 'playwright'
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { serveReaped } from './_serve-reaped.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const PORT = Number(process.env.PORT || 5399)
const BASE = process.env.BASE_URL || ('http://127.0.0.1:' + PORT)
const LOCALES = (process.env.LOCALES || 'ar,zh,ru,hi').split(',')
const LEAVES = ['resources-map', 'resources-list', 'data-tables', 'charts']
let srv = null
if (!process.env.BASE_URL) srv = serveReaped(resolve(REPO, 'public'), PORT)
const cat = (lc) => JSON.parse(readFileSync(resolve(REPO, 'library/i18n/locales/' + lc + '/gospel.json'), 'utf8'))
const flat = (o, p = []) => Object.entries(o).flatMap(([k, v]) =>
k.startsWith('_') ? [] : (v && typeof v === 'object' ? flat(v, [...p, k]) : [[[...p, k].join('.'), String(v)]]))
let browser
try {
browser = await chromium.launch()
const en = Object.fromEntries(flat(cat('en')))
const leaks = []
let compared = 0
for (const leaf of LEAVES) {
for (const lc of LOCALES) {
const loc = Object.fromEntries(flat(cat(lc)))
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
await page.goto(BASE + '/doxa-maps/gospel-resources/' + leaf + '/index.html?lang=' + lc,
{ waitUntil: 'networkidle', timeout: 90000 })
await page.waitForTimeout(8000)
/* ═══ READ ONLY THE CHROME, NOT THE DECLARED-ENGLISH PROSE ═══════════════════════
* The first version read the whole shadow root and reported 113 leaks, nearly all
* false: "JESUS Film" and "Scripture" appear inside the explanatory paragraphs this
* bundle deliberately keeps in English, and three-letter catalogue entries like "for"
* match inside ordinary English sentences. Both are noise, and a gate that cries 113
* when 0 are real is worse than none — someone would have gone and "fixed" strings
* that were already correct.
* Every English prose block in this bundle declares itself with dir="ltr". So the
* chrome is everything OUTSIDE those subtrees, which is exactly the population a
* translated key is supposed to reach. */
const text = await page.evaluate(() => {
const sr = document.querySelector('gospel-resources-map')?.shadowRoot
if (!sr) return ''
/* A ShadowRoot is not clonable, so walk it and skip declared-English subtrees. */
const parts = []
const walk = (node) => {
for (const child of node.childNodes) {
if (child.nodeType === 3) { parts.push(child.textContent); continue }
if (child.nodeType !== 1) continue
if (child.getAttribute && child.getAttribute('dir') === 'ltr') continue
if (child.tagName === 'STYLE' || child.tagName === 'SCRIPT') continue
walk(child)
}
}
walk(sr)
return parts.join(' ').replace(/\s+/g, ' ')
})
await page.close()
if (!text) { leaks.push(leaf + ' ' + lc + ': rendered nothing at all'); continue }
for (const [key, e] of Object.entries(en)) {
const t = loc[key]
/* Short entries match inside unrelated English words; six characters is the floor
at which a match is about the key rather than about the alphabet. */
if (!t || t === e || e.length < 6) continue
compared++
if (text.includes(e) && !text.includes(t)) leaks.push(leaf + ' ' + lc + ' ' + key + ': shows English "' + e.slice(0, 40) + '"')
}
}
}
console.log(' compared ' + compared + ' key/locale pair(s) across ' + LEAVES.length + ' leaves and ' + LOCALES.length + ' locales')
if (leaks.length) {
/* exitCode, NOT process.exit(). process.exit() terminates before pending stdout/stderr
writes flush, and this gate proved it on itself: its first real run returned 1 with
every explanatory line missing, so it failed without saying why. A gate that cannot
explain its own failure is barely better than no gate. */
console.error('GOSPEL-ENGLISH-LEAKS: FAIL — ' + leaks.length + ' translated key(s) rendering in English:')
for (const l of leaks.slice(0, 12)) console.error(' ' + l)
process.exitCode = 1
} else {
console.log('GOSPEL-ENGLISH-LEAKS: PASS — every key with a translation renders in it.')
}
} finally {
await browser?.close()
srv?.stop()
}

View file

@ -0,0 +1,151 @@
#!/usr/bin/env node
/**
* check-host-locale-contract — a HOST that passes profile-config.locale gets that language.
*
* THE CONTRACT. `profile-config.locale` is how a host page (the Nuxt site, a WordPress embed) tells
* an element which language to speak — ProfileLoader.vue reads it and drives the element's i18n. It
* does that through `inject('appI18n', null)`: a bundle whose index.js never `app.provide('appI18n')`
* hands ProfileLoader a null, the watchEffect does nothing, and the host's locale is SILENTLY
* IGNORED — no error, just English. Found in source 2026-09-25 in six bundles; this gate is what keeps
* the durable fix (createAppI18n's install provides appI18n itself) from quietly regressing.
*
* HOW, per leaf of every profile in the manifest (public/doxa-maps/<bundle>/<profile>/index.html):
* HOST the leaf page with ONE change — "locale":"ar" added to its BASE_CONFIG (the object it passes
* as profile-config). No ?lang=, and the page keeps its baked <html lang="en">, so the ONLY
* route to Arabic is the host contract under test.
* REF the same leaf with ?lang=ar (read by detectLocale when the instance is created — a different
* path that does not depend on appI18n being provided). It answers "does this page HAVE
* Arabic chrome to show?", so a page with no translatable text is reported n/a, not failed.
* Arabic-script characters (U+0600–U+06FF) are counted over every text node and the placeholder /
* aria-label / title attributes, through every shadow root, once the count stops changing.
* VERDICT per leaf: HOST ≥ half of REF → ok · otherwise FAIL · REF < MIN → n/a (nothing to judge),
* unless HOST ≥ MIN: then the contract holds and it is ?lang= the page ignores (ok, noted host-only).
* SETTLE: network idle (capped at 20 s), then the count must hold for 3 s. Measured 2026-09-25: a
* shorter settle read the control at 76 in one run and 2054 in the next.
* TODAY (2026-09-25, before upg100's createAppI18n fix): 18 leaves FAIL — every composite among them.
* POSITIVE CONTROL: regions-dashboard/regions-list (provides appI18n) must be ok, or the whole run is
* NOT MEASURED — a harness that cannot see Arabic there cannot say anything about the rest.
*
* USAGE node internal/check-host-locale-contract.mjs [--dir <tree root>] [--only bundle/profile,…]
* Serves THIS checkout's public/ on an OS-assigned port, or --dir (another commit's tree, for the
* before/after). Run through internal/browser-slot.sh.
* EXIT 0 every judged leaf enters the host's locale · 1 one does not · 75 not measured.
*/
import { chromium } from 'playwright'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
const ONLY = arg('--only', '') ? new Set(arg('--only', '').split(',')) : null
const CONTROL = 'regions-dashboard/regions-list'
const MIN = 20 // Arabic characters a page must show under ?lang=ar before it can be judged
const CONCURRENCY = 3
const manifestPath = join(DIR, 'doxa-maps', 'manifest.json')
if (!existsSync(manifestPath)) { console.log(`HOST-LOCALE: NOT MEASURED — no ${manifestPath}`); process.exit(75) }
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const leaves = []
for (const b of manifest.bundles || []) for (const p of b.profiles || []) {
const id = `${b.bundle}/${p.profile}`
if (ONLY && !ONLY.has(id) && id !== CONTROL) continue
if (existsSync(join(DIR, 'doxa-maps', id, 'index.html'))) leaves.push(id)
}
if (!leaves.includes(CONTROL)) { console.log(`HOST-LOCALE: NOT MEASURED — the control ${CONTROL} is not in this tree`); process.exit(75) }
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' })
const stop = () => { try { srv.kill() } catch { /* already gone */ } }
let up = false
for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/doxa-maps/manifest.json`).then((r) => r.ok).catch(() => false) }
if (!up) { stop(); console.log(`HOST-LOCALE: NOT MEASURED — could not serve ${DIR}`); process.exit(75) }
const COUNT = `(() => {
let n = 0; const AR = /[\\u0600-\\u06FF]/g
const add = (s) => { if (s) { const m = String(s).match(AR); if (m) n += m.length } }
const walk = (root) => {
const tw = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT)
for (let node = tw.currentNode; node; node = tw.nextNode()) {
if (node.nodeType === 3) { add(node.nodeValue); continue }
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') continue
for (const a of ['placeholder', 'aria-label', 'title']) add(node.getAttribute && node.getAttribute(a))
if (node.shadowRoot) walk(node.shadowRoot)
}
}
walk(document); return n
})()`
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
async function measure(id, mode) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
const page = await ctx.newPage()
const url = `http://127.0.0.1:${port}/doxa-maps/${id}/index.html${mode === 'ref' ? '?lang=ar' : ''}`
let injected = mode === 'ref'
if (mode === 'host') {
await page.route(url, async (route) => {
const res = await route.fetch()
const html = await res.text()
const out = html.replace(/var BASE_CONFIG = \{/, 'var BASE_CONFIG = {"locale":"ar",')
injected = out !== html
await route.fulfill({ response: res, body: out })
})
}
try {
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
// SETTLE. A first version stopped after three equal reads 500 ms apart and read the control at 76
// Arabic characters in one run and 2054 in another: regions-list's feed takes over a second, and
// the page sat still (in English placeholders) until it arrived. So: wait for the network to go
// quiet (capped — a map streams tiles for ever), then require the count to hold for 3 s.
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {})
let last = -1, same = 0, n = 0
for (let i = 0; i < 60 && same < 6; i++) {
await page.waitForTimeout(500)
n = await page.evaluate(COUNT).catch(() => 0)
same = n === last ? same + 1 : 0; last = n
}
return { n, injected }
} catch (e) { return { n: null, injected, error: String(e.message || e).split('\n')[0] } }
finally { await ctx.close() }
}
const rows = []
async function judge(id) {
const host = await measure(id, 'host'), ref = await measure(id, 'ref')
let verdict, why = host.error || ref.error || (!host.injected ? 'no BASE_CONFIG in the page' : '')
if (!host.injected) verdict = 'unmeasured' // the page has no BASE_CONFIG to set
else if (host.n === null || ref.n === null) verdict = 'unmeasured'
// ?lang=ar showed no Arabic but the host path did: the page is translatable and the CONTRACT holds;
// it is ?lang= that page ignores — a different defect, reported, not failed here.
else if (ref.n < MIN && host.n >= MIN) { verdict = 'ok'; why = 'host-only: this page ignores ?lang=' }
else if (ref.n < MIN) verdict = 'n/a'
else verdict = host.n >= ref.n / 2 ? 'ok' : 'FAIL'
rows.push({ id, host: host.n, ref: ref.n, verdict, why })
}
// The control runs ALONE first, so it is never starved by three maps loading beside it.
await judge(CONTROL)
const queue = leaves.filter((l) => l !== CONTROL)
await Promise.all(Array.from({ length: CONCURRENCY }, async () => { while (queue.length) await judge(queue.shift()) }))
await browser.close(); stop()
rows.sort((a, b) => (a.id === CONTROL ? -1 : b.id === CONTROL ? 1 : a.id.localeCompare(b.id)))
console.log(`HOST-LOCALE: profile-config.locale="ar" (HOST) vs ?lang=ar (REF), Arabic-script characters drawn, ${DIR}`)
for (const r of rows) console.log(` ${r.verdict.padEnd(10)} ${r.id.padEnd(48)} host ${String(r.host ?? '—').padStart(5)} ref ${String(r.ref ?? '—').padStart(5)}${r.id === CONTROL ? ' ← positive control' : ''}${r.why ? ' ' + r.why : ''}`)
const control = rows.find((r) => r.id === CONTROL)
if (!control || control.verdict !== 'ok') {
console.log(`HOST-LOCALE: NOT MEASURED — the positive control ${CONTROL} did not enter Arabic (host ${control && control.host}, ref ${control && control.ref}); this harness cannot judge the rest.`)
process.exit(75)
}
const fails = rows.filter((r) => r.verdict === 'FAIL'), un = rows.filter((r) => r.verdict === 'unmeasured'), na = rows.filter((r) => r.verdict === 'n/a')
if (fails.length) {
console.log(`HOST-LOCALE: FAIL — ${fails.length} leaf/leaves ignore the host's profile-config.locale although they can speak Arabic: ${fails.map((r) => r.id).join(', ')}`)
console.log(' The usual cause: the bundle never provides appI18n, so ProfileLoader\'s inject(\'appI18n\') is null.')
process.exit(1)
}
if (un.length) { console.log(`HOST-LOCALE: NOT MEASURED — ${un.length} leaf/leaves could not be read: ${un.map((r) => r.id).join(', ')}`); process.exit(75) }
console.log(`HOST-LOCALE: PASS — ${rows.length - na.length} leaves enter the host's locale; ${na.length} have no Arabic chrome to judge (n/a).`)

View file

@ -0,0 +1,168 @@
#!/usr/bin/env node
/**
* check-poster-wall-localised — the SHARED poster wall's own chrome must be translated
* on every leaf that mounts it, in every locale.
*
* ★ WHY THIS CANNOT BE CHECKED IN ENGLISH, which is the whole reason the gap survived.
* PosterWall guards useI18n() (it THROWS on a host that never installed vue-i18n, which blanked
* people-groups-grid once via library FilterDropdown). The fallback HUMANIZES the key:
* buttons.prayerList -> "Prayer List" buttons.printOptions -> "Print Options"
* That is deliberate and good — a bare host shows readable English rather than a raw dotted key
* or a blank button. But it means "the button says Prayer List" is TRUE both when i18n worked and
* when it threw. An en-only check passes forever while every other locale leaks English.
*
* So this gate runs in NON-ENGLISH locales only. There, the translated string differs from the
* humanized key, so seeing the humanized key IS the defect, with no ambiguity.
*
* ⚠ IT CHECKS THE RENDERED PAGE, NOT THE SOURCE. A static scan can see `useI18n()` wrapped in a
* try/catch and conclude either "guarded, fine" or "might throw, bad" — neither is a measurement.
* Whether the catch FIRES depends on what the host installed at the mount point, which only
* exists at runtime. A scoreboard entry for this was open with nothing able to confirm or refute
* it; that is what this closes.
*
* Usage: node internal/check-poster-wall-localised.mjs [baseUrl]
* SKIPs when nothing is served; exits 2 (NOT MEASURED) when the server is not this tree.
*/
import { chromium } from 'playwright'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { servesThisTree } from './_serves-this-tree.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const BASE = process.argv[2] || process.env.BASE_URL || 'http://127.0.0.1:4173'
// EVERY leaf that mounts the shared wall. key-cities and regions-dashboard are here because they
// consume PosterWall too — the original report named only three, and a gate that copies a
// report's sampling inherits its blind spot.
const LEAVES = [
'my-upg-100-list/upg-100-mfe',
'my-upg-100-list/poster-wall',
'country-posters/country-poster-wall',
'key-cities/city-poster-wall',
'regions-dashboard/regions-list',
]
// ro is the widest-rendering Latin locale, ar is RTL, zh is double-width CJK: three different
// ways a translation can be wrong, and all three differ from the humanized English key.
const LANGS = (process.env.PW_LANG || 'ro,ar,zh').split(',').map((s) => s.trim()).filter(Boolean)
// The humanizeKey outputs for every key the wall renders. Seeing ONE of these in a non-English
// locale means the fallback fired. Derived from the same transform the component uses, so the
// two cannot drift: last path segment, camelCase split, first letter upper.
const KEYS = ['buttons.prayerList', 'buttons.posterMap', 'buttons.cardDeck', 'buttons.printOptions',
'buttons.postersPerRow', 'buttons.showOnMap', 'buttons.clearSelection', 'buttons.cardStockAria']
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
const FALLBACKS = new Set(KEYS.map(humanizeKey))
let reachable = false
try { const r = await fetch(`${BASE}/doxa-maps/manifest.json`, { signal: AbortSignal.timeout(4000) }); reachable = r.ok } catch { /* precondition */ }
if (!reachable) {
console.log(`POSTER-WALL-LOCALISED: SKIP — nothing served at ${BASE}.`)
console.log(' Serve the built tree: bun run serve (or set BASE_URL).')
process.exit(0)
}
const treeCheck = await servesThisTree(BASE, ROOT)
if (!treeCheck.ok) {
console.error('POSTER-WALL-LOCALISED: NOT MEASURED — the server is not serving this checkout.')
console.error(` ${treeCheck.why}`)
process.exit(2)
}
const WALK = `(root) => { const o=[]; const rec=(r)=>{ for (const e of r.querySelectorAll('*')) { o.push(e); if (e.shadowRoot) rec(e.shadowRoot) } }; rec(root); return o }`
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const rows = []
const fails = []
for (const leaf of LEAVES) {
for (const lang of LANGS) {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
const errs = []
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
let r = null
try {
const res = await page.goto(`${BASE}/doxa-maps/${leaf}/index.html?lang=${lang}`,
{ waitUntil: 'load', timeout: 45000 })
if (!res || !res.ok()) { rows.push({ leaf, lang, skipped: `HTTP ${res ? res.status() : '?'}` }); await page.close(); continue }
await page.waitForTimeout(13000)
r = await page.evaluate(`(() => {
const all = (${WALK})(document)
const cls = (e, n) => new RegExp('\\\\b' + n + '\\\\b').test(e.className || '')
const texts = (n) => [...new Set(all.filter((e) => cls(e, n)).map((e) => e.textContent.trim()).filter(Boolean))]
const legend = all.find((e) => e.tagName === 'LEGEND' && e.parentElement && /\\bpfs\\b/.test(e.parentElement.className || ''))
const perrow = all.find((e) => cls(e, 'perrow__label'))
return {
cards: all.filter((e) => cls(e, 'card')).length,
chrome: [
...texts('pbtn'),
...(legend ? [legend.textContent.trim()] : []),
...(perrow ? [perrow.textContent.trim()] : []),
...texts('pstock'),
],
showOnMap: texts('tape-h'),
}
})()`)
} catch (e) {
rows.push({ leaf, lang, skipped: String(e.message || e).slice(0, 60) })
await page.close(); continue
}
await page.close()
// ⚠ TWO WAYS THIS GATE CAN PASS WITHOUT CHECKING ANYTHING, and both were REAL on the first
// run — a gate that cannot tell "clean" from "saw nothing" is the same failure it exists to
// catch. A wall with no cards renders no chrome; and regions-list rendered 7 cards with
// chrome 0, because that profile mounts the wall WITHOUT the print row, so there was no
// translatable string on the page at all and "all translated" was vacuously true.
if (!r.cards) { rows.push({ leaf, lang, skipped: 'no poster cards rendered' }); continue }
if (!r.chrome.length && !r.showOnMap.length) {
rows.push({ leaf, lang, skipped: `${r.cards} card(s) but ZERO wall chrome — nothing to check` })
continue
}
// "Show on map" is a COMPOSED string ("116 UPG·Show on map"), so compare by containment.
const leaked = [
...r.chrome.filter((t) => FALLBACKS.has(t)),
...r.showOnMap.filter((t) => [...FALLBACKS].some((f) => t.includes(f))),
]
rows.push({ leaf, lang, cards: r.cards, chrome: r.chrome.length, leaked, errs: errs.length })
if (leaked.length) {
fails.push(`${leaf} @ ${lang}: ${leaked.length} untranslated string(s) — ${[...new Set(leaked)].join(' · ')}`)
}
if (errs.length) fails.push(`${leaf} @ ${lang}: ${errs.length} page error(s) — ${errs[0]}`)
}
}
await browser.close()
console.log('POSTER-WALL-LOCALISED')
console.log(` base : ${BASE}`)
for (const row of rows) {
if (row.skipped) { console.log(` ${row.leaf} @ ${row.lang} SKIP — ${row.skipped}`); continue }
console.log(` ${row.leaf} @ ${row.lang} cards ${row.cards} · chrome ${row.chrome} string(s) · `
+ `${row.leaked.length ? `LEAKED ${row.leaked.length}` : 'all translated'} · ${row.errs} error(s)`)
}
const measured = rows.filter((x) => !x.skipped).length
const skipped = rows.filter((x) => x.skipped)
if (!measured) {
console.error('\nPOSTER-WALL-LOCALISED: NOT MEASURED — every leaf skipped, so nothing was checked.')
process.exit(2)
}
// ★ NO SILENT CAPS. A leaf that never got measured is not a leaf that passed, and a summary that
// counts only the green ones reads as full coverage. Name what was dropped and why, every run —
// key-cities ships NO leaf pages (bundle JS only), so it can never be reached by URL, and that
// is a coverage fact a reader of this output needs rather than a 404 buried mid-list.
if (skipped.length) {
const byLeaf = new Map()
for (const s of skipped) byLeaf.set(s.leaf, s.skipped)
console.log(`\n NOT COVERED (${skipped.length} combination(s)):`)
for (const [leaf, why] of byLeaf) console.log(` ${leaf} — ${why}`)
}
if (fails.length) {
console.error(`\nPOSTER-WALL-LOCALISED: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(` - ${f}`)
console.error('\n The shared wall humanizes its i18n keys when useI18n() throws, so the fallback')
console.error(' reads as fluent English ("Prayer List"). That is why this can only be seen in a')
console.error(' non-English locale, and why an en-only check would pass while every other')
console.error(' locale leaked.')
process.exit(1)
}
console.log(`\nPOSTER-WALL-LOCALISED: PASS — ${measured} leaf x locale combination(s), `
+ `${LANGS.join('/')}. The shared wall's own chrome is translated everywhere it mounts.`)

View file

@ -13,6 +13,8 @@
* each way) that is not blank: many distinct colours, mostly non-white. The image is saved so a
* human can READ it: streets, building footprints, the zone outline on top.
* 3. LOCALE — ?lang=es gives a Spanish toolbar. 4. 0 page errors on the print page.
* 5. FULL SCREEN — from a 520px embed, the whole planner takes the viewport.
* 6. PRINT RESOLUTION — the printed map measures >= 300 DPI at the sheet's printable width.
*
* Starts its own servers on OS-assigned ports; needs only a built tree. Run it inside the shared
* browser locks, like every browser gate. OUT=<dir> chooses where the print PNG is written.
@ -69,6 +71,17 @@ try {
console.log(`${ok ? 'PASS' : 'FAIL'} size ${where} ${W}x${H}: ${JSON.stringify(m)} ${errs.length ? 'ERR ' + errs.join(' | ') : '0 errors'}`)
await p.close()
}
// 1b. FULL SCREEN from inside a small embed: the whole planner (toolbar + map) takes the viewport.
{
const p = await b.newPage({ viewport: { width: 1280, height: 800 } })
const fsHost = createServer((q, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(`<!doctype html><body style="margin:0"><h1 style="height:80px;margin:0">host</h1><script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa tab="prayer-walk-planner" style="height:520px"></doxa-maps-spa></body>`) })
const FP = await new Promise(ok => fsHost.listen(0, 'localhost', () => ok(fsHost.address().port)))
await p.goto(`http://localhost:${FP}/`); await p.waitForTimeout(9000)
const fs = await p.evaluate(`(async () => { const el = (${findEl.toString()})(); const btn = el.shadowRoot.querySelector('.pwp-map-controls button'); if (!btn) return null; const before = Math.round(el.shadowRoot.querySelector('canvas').getBoundingClientRect().height); btn.click(); await new Promise(r => setTimeout(r, 2500)); const r = el.getBoundingClientRect(); return { before, after: Math.round(el.shadowRoot.querySelector('canvas').getBoundingClientRect().height), host: [Math.round(r.width), Math.round(r.height)] } })()`)
const fok = !!fs && fs.host[0] >= 1270 && fs.host[1] >= 790 && fs.after > fs.before
console.log(`${fok ? 'PASS' : 'FAIL'} full screen from a 520px embed: ${JSON.stringify(fs)}`); results.push({ check: 'full screen', ok: fok })
await p.close(); fsHost.close()
}
// 2. PRINT MAP of a real zone (Balat, Istanbul: dense houses on small streets) + 4. LOCALE (es)
const p = await b.newPage({ viewport: { width: 1280, height: 800 } }); const errs = []
p.on('pageerror', e => errs.push(String(e).slice(0, 140)))
@ -86,6 +99,24 @@ try {
console.log(`${pok ? 'PASS' : 'FAIL'} print map: ${JSON.stringify(st)} -> ${OUT}/pwp-print-balat.png`); results.push({ check: 'print map', ok: pok, st })
} else { console.log('FAIL print map: renderZoneMap returned null'); results.push({ check: 'print map', ok: false }) }
console.log(`${errs.length ? 'FAIL' : 'PASS'} page errors on print page: ${errs.length} ${errs.join(' | ')}`); results.push({ check: 'errors', ok: !errs.length })
// 5. PRINT RESOLUTION, MEASURED: the real printed sheet laid out as PRINT at the printable width
// (8.5in - 2 x 12mm @page margins = 725 CSS px; emulateMedia alone does NOT apply @page margins,
// which is how a first measurement read the frame as 8.48in wide). DPI = natural px / printed in.
// The Driver: "a print that stays sharp when zoomed" — at >= 300 DPI a zoomed PDF shows real
// street names and house outlines, not an upscaled screen grab.
await p.evaluate(() => { new MutationObserver(ms => { for (const m of ms) for (const n of m.addedNodes) if (n.tagName === 'IFRAME') { try { n.contentWindow.print = () => {} } catch (_) {} window.__pwpFrame = n } }).observe(document.body, { childList: true }) })
await p.evaluate(`(async () => { const el = (${findEl.toString()})(); const z = el.listPolygons().find(x => x.title === 'Balat'); el.selectPolygon(z.id, true); await el.print() })()`)
await p.waitForTimeout(3000)
const sheet = await p.evaluate(() => window.__pwpFrame ? window.__pwpFrame.contentDocument.documentElement.outerHTML : '')
if (sheet) {
const q = await b.newPage(); await q.emulateMedia({ media: 'print' }); await q.setViewportSize({ width: 725, height: 965 })
await q.setContent('<!DOCTYPE html>' + sheet.replace(/<script>[\s\S]*?<\/script>/g, ''), { waitUntil: 'load' }); await q.waitForTimeout(1200)
const dpi = await q.evaluate(() => { const i = document.querySelector('.snapshot'); if (!i) return null; const r = i.getBoundingClientRect(); return { naturalPx: [i.naturalWidth, i.naturalHeight], printedInches: [+(r.width / 96).toFixed(2), +(r.height / 96).toFixed(2)], dpi: [Math.round(i.naturalWidth / (r.width / 96)), Math.round(i.naturalHeight / (r.height / 96))] } })
const dok = !!dpi && dpi.dpi[0] >= 300 && dpi.dpi[1] >= 300
console.log(`${dok ? 'PASS' : 'FAIL'} print resolution: ${JSON.stringify(dpi)}`); results.push({ check: 'print dpi', ok: dok })
await q.close()
} else { console.log('FAIL print resolution: the print frame never appeared'); results.push({ check: 'print dpi', ok: false }) }
await p.close()
} finally { await b.close(); host.close(); srv.kill() }
const bad = results.filter(r => !r.ok).length

View file

@ -32,8 +32,14 @@
* actual side by side.
*/
import { chromium } from 'playwright'
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
import { fileURLToPath as __f2p } from 'node:url'
import { dirname as __dn, resolve as __rs } from 'node:path'
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/doxa-maps'
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
await __treeGuard(BASE, __ROOT, 'SPA-TAB-ORDER')
// The Driver's sequence, verbatim from the open-work brief. Prefix-matched (case-insensitive,
// whitespace-collapsed) so a row may carry its subtitle.

View file

@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* check-staging-forwards-lang — a staging page opened at ?lang=ar embeds its leaves in Arabic.
*
* THE BUG (found by gospel 2026-09-25). The tree's staging page (public/doxa-maps/index.html, built
* from internal/staging-forge.js) built each embedded leaf's URL as withProvider(withTk(href)) — the
* token and the engine rode along, the language did not. The leaf pages bake <html lang="en">, so a
* maintainer or reviewer reading the tree at ?lang=ar saw every map in English, and the leaf was
* right to do so: nothing told it otherwise.
*
* WHAT IS ASSERTED, on the served tree:
* 1. /doxa-maps/index.html?lang=ar, gospel-resources/resources-map selected: the stage iframe's src
* AND the "open ↗" link both carry lang=ar;
* 2. inside that iframe the Gospel map's mode toggle reads gospel.mode.gap in Arabic ("الفجوة",
* from library/i18n/locales/ar/gospel.json) — the language actually arrived, not just the param.
*
* USAGE node internal/check-staging-forwards-lang.mjs [--dir <tree root>] (through browser-slot.sh)
* EXIT 0 forwarded and rendered · 1 not · 75 not measured.
* NEGATIVE-TESTED 2026-09-25: on the tree built before withLang() existed it FAILS at step 1.
*/
import { chromium } from 'playwright'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
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 argv = process.argv.slice(2)
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
const KEY = 'gospel-resources/resources-map'
const GAP_AR = JSON.parse(readFileSync(resolve(ROOT, 'library/i18n/locales/ar/gospel.json'), 'utf8')).mode.gap
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' })
const stop = () => { try { srv.kill() } catch { /* already gone */ } }
let up = false
for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/doxa-maps/index.html`).then((r) => r.ok).catch(() => false) }
if (!up) { stop(); console.log(`STAGING-LANG: NOT MEASURED — could not serve ${DIR}`); process.exit(75) }
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
const done = async (code, msg) => { console.log(msg); await browser.close(); stop(); process.exit(code) }
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
await page.goto(`http://127.0.0.1:${port}/doxa-maps/index.html?lang=ar`, { waitUntil: 'load' })
const node = page.locator(`.node[data-key="${KEY}"]`)
if (!(await node.count())) await done(75, `STAGING-LANG: NOT MEASURED — no ${KEY} node on the staging page`)
await node.first().click()
await page.waitForSelector('#stage iframe', { timeout: 15000 }).catch(() => {})
const src = await page.locator('#stage iframe').first().getAttribute('src').catch(() => null)
const pop = await page.locator('#stage a.popout').first().getAttribute('href').catch(() => null)
if (!src) await done(75, 'STAGING-LANG: NOT MEASURED — the stage never rendered an iframe')
const carries = (u) => new URL(u, 'http://x/').searchParams.get('lang') === 'ar'
console.log(` iframe src ${carries(src) ? 'carries lang=ar' : 'NO lang'} ${src}`)
console.log(` open ↗ ${pop && carries(pop) ? 'carries lang=ar' : 'NO lang'} ${pop}`)
if (!carries(src) || !pop || !carries(pop)) await done(1, 'STAGING-LANG: FAIL — the staging page does not forward ?lang= to the leaf it embeds.')
const frame = page.frameLocator('#stage iframe')
let label = null
for (let i = 0; i < 60 && label === null; i++) {
await page.waitForTimeout(500)
label = await frame.locator('body').evaluate(() => {
// the toggle lives inside the element's shadow root (possibly nested) — walk every root
const f = (r) => {
const b = [...r.querySelectorAll('button.gm-mode')]
if (b.length) return b.map((x) => x.textContent.trim())
for (const e of r.querySelectorAll('*')) if (e.shadowRoot) { const m = f(e.shadowRoot); if (m) return m }
return null
}
return f(document)
}).catch(() => null)
}
if (!label) await done(75, 'STAGING-LANG: NOT MEASURED — the Gospel map\'s mode toggle never rendered in the iframe')
console.log(` mode toggle ${JSON.stringify(label)} (expect "${GAP_AR}")`)
if (!label.includes(GAP_AR)) await done(1, `STAGING-LANG: FAIL — lang=ar reached the iframe but the Gospel map did not render gospel.mode.gap in Arabic.`)
await done(0, 'STAGING-LANG: PASS — ?lang=ar is forwarded to the embedded leaf and the Gospel map renders in Arabic.')

View file

@ -14,8 +14,14 @@
* Plugin gallery dev server: --url http://localhost:5174/plugin-designs/doxa-simple-map/index.html
*/
import { launch, TELEMETRY } from './lib/browser.mjs'
import { requireDevServesThisCheckout } from './_serves-this-tree.mjs'
import { fileURLToPath as __f2p } from 'node:url'
import { dirname as __dn, resolve as __rs } from 'node:path'
const args = process.argv.slice(2); const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
const URL = argOf('--url', 'http://localhost:3001/pray'), TAG = argOf('--tag', 'doxa-map'), W = +argOf('--width', 1440), H = +argOf('--height', 900), ROWS = +argOf('--rows', 8)
// Guard ONLY when pointed at OUR dev gallery (/plugin-designs/…). The default target is the DX-life
// site (a different app this probe compares against on purpose), which has no source of ours to match.
if (/\/plugin-designs\//.test(URL)) await requireDevServesThisCheckout(URL, __rs(__dn(__f2p(import.meta.url)), '..'), 'LEGEND-COLUMNS')
const browser = await launch('firefox', { label: 'legend-columns' })
const ctx = await browser.newContext({ viewport: { width: W, height: H }, hasTouch: true, isMobile: W < 600 }); const page = await ctx.newPage()
await page.route(TELEMETRY, r => r.abort())

View file

@ -56,12 +56,18 @@
import { writeFileSync, mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { launch, DEEP_FIND_SRC, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
import { fileURLToPath as __f2p } from 'node:url'
import { dirname as __dn, resolve as __rs } from 'node:path'
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
const TOOL = 'long-frames-probe'
const args = process.argv.slice(2)
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
const argAll = (k) => args.flatMap((a, i) => a === k && args[i + 1] ? [args[i + 1]] : [])
const BASE = String(argOf('--base', 'http://localhost:5174')).replace(/\/+$/, '')
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
await __devGuard(BASE, __ROOT, 'LONG-FRAMES')
const URLS = argAll('--url')
const TAGS = argAll('--tag')
const GREP = argOf('--grep', '')

View file

@ -61,11 +61,17 @@
import { writeFileSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { launch, TELEMETRY } from './lib/browser.mjs'
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
import { fileURLToPath as __f2p } from 'node:url'
import { dirname as __dn, resolve as __rs } from 'node:path'
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
const TOOL = 'mobile-sheet-probe'
const args = process.argv.slice(2)
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
const URL = String(argOf('--url', 'http://localhost:5174/plugin-designs/doxa-simple-map/index.html'))
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
await __devGuard(URL, __ROOT, 'MOBILE-SHEET')
const TAG = argOf('--tag', 'doxa-map')
const NTH = Number(argOf('--nth', 0))
const INSTANCE = argOf('--instance', '')

View file

@ -30,6 +30,10 @@
*/
import { writeFileSync } from 'node:fs'
import { launch, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
import { fileURLToPath as __f2p } from 'node:url'
import { dirname as __dn, resolve as __rs } from 'node:path'
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
const args = process.argv.slice(2)
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
@ -40,6 +44,8 @@ const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1]
// cannot reach its subject has not passed and has not failed: it has not run, and the message
// blames the user for not starting a server they had started.
const BASE = String(argOf('--url', process.env.DEV_URL || 'http://localhost:5173')).replace(/\/+$/, '')
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
await __devGuard(BASE, __ROOT, 'PLUGIN-GALLERY-SMOKE')
// Paint needs a GPU this build box does not have; see the long note at the canvas assertion below.
const EXPECT_CANVAS = process.env.EXPECT_CANVAS === '1'

View file

@ -385,6 +385,10 @@ await shot('prod-prayer-phone', '/doxa-maps/doxa-simple-map/doxa-simple-map/pray
await shot('prod-cities-internet-phone', '/doxa-maps/cities-internet/connectivity-map/index.html', { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 })
await shot('prod-gospel-resources-phone', '/doxa-maps/gospel-resources/resources-map/index.html', { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 })
// ---- EXPERIMENTAL SHELF: the plugins the README pictures there ----
await shot('exp-wall-of-people-groups', '/doxa-maps/wall-of-people-groups/wall-of-people-groups/index.html', { wait: 12000, minElements: 40 })
await shot('exp-prayer-walk-planner', '/doxa-maps/prayer-walk-planner/prayer-walk-planner/index.html', { wait: 12000, minElements: 40 })
// ---- DEPLOY2: mobile — the UPG-100 list as the phone app ----
// The mobile example is UPG-100 (ruled 2026-09-24; Simple Research was retired as the example because
// it was never built for phones). Shot once its owner marked the phone layout ready at 88a566f:

View file

@ -44,6 +44,10 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
import { fileURLToPath as __f2p } from 'node:url'
import { dirname as __dn, resolve as __rs } from 'node:path'
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
import { launch, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
const TOOL = 'share-smoke'
@ -63,6 +67,8 @@ const GREP = argOf('--grep', '')
const GALLERY = has('--plugin-gallery')
? String((() => { const v = args[args.indexOf('--plugin-gallery') + 1]; return v && !v.startsWith('--') ? v : 'http://localhost:5174' })()).replace(/\/+$/, '')
: null
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
if (GALLERY) await __devGuard(GALLERY, __ROOT, 'SHARE-ROUNDTRIP')
if (!URLS.length && !GALLERY) {
console.error(`[${TOOL}] usage: --url <host page URL> [--url ...] [--tag <tag>] | --plugin-gallery [base] (see header)`)

View file

@ -47,12 +47,18 @@
* removing tree-base makes it FAIL on the empty tab list. A gate that cannot fail is worse than none.
*/
import { chromium } from 'playwright'
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
import { fileURLToPath as __f2p } from 'node:url'
import { dirname as __dn, resolve as __rs } from 'node:path'
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
const argOf = (flag, dflt) => {
const i = process.argv.indexOf(flag)
return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : dflt
}
const BASE = String(argOf('--url', 'http://localhost:5173')).replace(/\/+$/, '')
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
await __devGuard(BASE, __ROOT, 'SMOKE-STUDIO')
const BUDGET_MS = Number(process.env.SMOKE_BUDGET_MS || 20000)
// Deep walker — SHADOW DOM DOES NOT PIERCE. A flat querySelectorAll lies about this component, and a

View file

@ -49,6 +49,15 @@ function withProvider(href) {
const p = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('provider') : null
return p ? href + (href.includes('?') ? '&' : '?') + 'provider=' + encodeURIComponent(p) : href
}
// Carry the page's ?lang= (or ?locale=) into the embedded leaf the same way. The leaf pages bake
// <html lang="en">, so without this a staging page opened at ?lang=ar embedded every leaf in
// English — measured: 0 of 1 iframes carried it. Only forwarded when present; the leaf's own
// detectLocale() still decides what it supports.
function withLang(href) {
const q = typeof location !== 'undefined' ? new URLSearchParams(location.search) : null
const l = q && (q.get('lang') || q.get('locale'))
return l ? href + (href.includes('?') ? '&' : '?') + 'lang=' + encodeURIComponent(l) : href
}
// ── Load the semantic tree (inline-first; fetch = dev fallback) ──────────────
async function loadTree() {
@ -151,10 +160,10 @@ function renderStage() {
<div class="stage-head">
<span class="sctag">&lt;${esc(nd.element || nd.label)}&gt;</span>
<span class="sckind">${esc(nd.kind)}</span>
<a class="popout" href="${esc(withProvider(withTk(nd.href)))}" target="_blank" rel="noopener">open ↗</a>
<a class="popout" href="${esc(withLang(withProvider(withTk(nd.href))))}" target="_blank" rel="noopener">open ↗</a>
<button class="popout" id="copyEmbed" type="button" title="Copy an iframe snippet with this host's full URL">copy embed</button>
</div>
<div class="map-slot"><iframe allowfullscreen src="${esc(withProvider(withTk(nd.href)))}"></iframe></div>`
<div class="map-slot"><iframe allowfullscreen src="${esc(withLang(withProvider(withTk(nd.href))))}"></iframe></div>`
// COPY EMBED — the partner handoff. The snippet is built AT CLICK TIME from
// location.href, so it always carries the FULL URL of whatever host serves
// this tree (localhost, doxa.life/embed/doxa-maps-build/…, any CDN) — embeds

View file

@ -84,6 +84,9 @@ window.MANIFEST = {
const tk = new URLSearchParams(location.search).get('tk') || '';
const withTk = u => u ? (tk ? u + (u.includes('?')?'&':'?') + 'tk=' + encodeURIComponent(tk) : u) : '';
/* Carry ?lang= / ?locale= into the lazy leaf iframe too — leaves bake <html lang="en">. */
const qlang = new URLSearchParams(location.search).get('lang') || new URLSearchParams(location.search).get('locale') || '';
const withLang = u => u ? (qlang ? u + (u.includes('?')?'&':'?') + 'lang=' + encodeURIComponent(qlang) : u) : '';
const el = (tag, cls, html) => { const n=document.createElement(tag); if(cls)n.className=cls; if(html!=null)n.innerHTML=html; return n; };
/* ONE node renderer used for all three kinds — the data/presentation split. */
@ -98,7 +101,7 @@ function node(kind, name, hint, id, frameUrl, children){
if(frameUrl){
const f = el('div','frame'); const ifr = el('iframe'); ifr.loading='lazy';
f.append(ifr); d.append(f);
d.addEventListener('toggle', () => { if(d.open && !ifr.src) ifr.src = withTk(frameUrl); });
d.addEventListener('toggle', () => { if(d.open && !ifr.src) ifr.src = withLang(withTk(frameUrl)); });
}
(children||[]).forEach(c => d.append(c));
return d;

View file

@ -119,11 +119,17 @@
import { writeFileSync, mkdirSync, readFileSync, rmSync, mkdtempSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
import { fileURLToPath as __f2p } from 'node:url'
import { dirname as __dn, resolve as __rs } from 'node:path'
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
import { launch, DEEP_FIND_SRC, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
const args = process.argv.slice(2)
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
const BASE = String(argOf('--url', 'http://localhost:5174')).replace(/\/+$/, '')
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
await __devGuard(BASE, __ROOT, 'THEME-FLASH')
const GREP = argOf('--grep', '')
const TARGET = argOf('--target', '')
const TAG = argOf('--tag', '')

View file

@ -87,6 +87,9 @@ const DEFAULT_LEAVES = [
'my-upg-100-list/upg-100-list/index.html',
'my-upg-100-list/upg-100-mfe/index.html',
'research-dashboard/research-dashboard/index.html',
// added 2026-09-25 (the regions map was never painted by this gate), verified PASS local and live
'regions-dashboard/region-dashboards/index.html',
'regions-dashboard/regions-mfe/index.html',
]
const urls = process.argv.slice(2).length

View file

@ -15,6 +15,7 @@
"tree": "node internal/generate-tree.mjs",
"gallery": "node internal/generate-gallery.mjs",
"check:drop": "node internal/drop-portability-check.mjs",
"check:host-locale-contract": "node internal/check-host-locale-contract.mjs",
"check:parse": "node internal/inline-parse-check.mjs",
"verify": "node library/scripts/research/verify-all.mjs",
"verify:full": "node library/scripts/research/verify-all.mjs --full",
@ -126,6 +127,7 @@
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
"check:url-resolver": "node internal/check-url-resolver.mjs",
"check:studio-all": "node internal/check-studio-every-plugin.mjs",
"check:basemap-labels": "node internal/check-basemap-labels-localized.mjs",
"check:vision-card-numbers": "node internal/check-vision-card-numbers.mjs",
"check:map-embed-cells": "node internal/check-map-embed-cells.mjs",
"check:map-unmount-race": "node internal/check-map-unmount-guard.mjs",
@ -168,6 +170,7 @@
"check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs",
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
"check:click-sheet": "node plugin-designs/gospel-resources/scripts/verify-gospel-click-sheet.mjs",
"check:staging-lang": "node internal/check-staging-forwards-lang.mjs",
"check:stale-sweep-claim": "node internal/check-no-stale-sweep-claim.mjs # a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `the check:stale-sweep-claim:poison entry`. Static, sub-second.",
"check:surfaces-read-the-tier": "node internal/check-surfaces-read-the-tier.mjs",
"check:surfaces-read-the-tier:poison": "node internal/check-surfaces-read-the-tier.mjs --poison",
@ -202,7 +205,11 @@
"check:leaves-mount:poison": "node internal/check-gospel-leaves-mount.mjs --poison",
"check:gospel-all": "node internal/check-gospel-all.mjs",
"check:no-conflict-markers": "node internal/check-no-conflict-markers.mjs",
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison"
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison",
"check:english-leaks": "node internal/check-gospel-english-leaks.mjs",
"check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs",
"check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs",
"check:experimental-shelf": "node internal/check-experimental-shelf.mjs"
},
"//scripts": {
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
@ -225,6 +232,7 @@
"check:facet-changes-map:self-test": "reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.",
"check:heartbeat": "the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
"check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
"check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir <tree> for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.",
"check:provider-switch": "the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.",
"check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.",
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
@ -277,6 +285,7 @@
"check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
"check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
"check:click-sheet": "the COUNTRY-CLICK SHEET without a GPU: every country in the roster resolves to >=1 pin, every link the sheet would render is absolute, and has_audio stays the STRING \"unknown\" where the pool was never checked (collapsing it to false would read as \"no audio exists\"). It had been CRASHING on a path deleted when data moved out of public/, and its two inputs were a private by-country copy and the curated 2,106-row subset the sheet stopped reading — so it was unrunnable AND aimed at files the surface no longer uses. Joins by ISO3 now, not by country-name substring. Carries its own 4-case self-test. Static, sub-second.",
"check:staging-lang": "the tree's staging page (public/doxa-maps/index.html, from internal/staging-forge.js) opened at ?lang=ar forwards lang=ar to the leaf it embeds (iframe src AND the open link), and the Gospel map inside renders gospel.mode.gap in Arabic. Negative-tested: the tree built before withLang() FAILS. --dir <tree>. Through browser-slot.sh.",
"check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.",
"check:surfaces-read-the-tier": "a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.",
"check:link-rot": "re-tests a stratified sample of links we already VERIFIED, because nothing else ever re-tests one: a publisher can retire a page and the map keeps offering it, still labelled 'link checked'. THREE BANDS, each measured rather than assumed, each with its controls in band every run: plain STATUS where a status is evidence (jesusfilm.org among them — it does 404 on the /watch/jesus/<slug> shape the map ships, and the old exclusion was inherited from the retired ?languageId= URL); API-MAPPED for live.bible.is, whose page answers 200 for any fileset while /api/bibles/<id> answers 404; and REDIRECT-LANDING for fivefish.org, where a published code lands on its numbered language page and an unknown one is swept to /regions. If a control misbehaves it reports PROVED NOTHING rather than a pass. Only knowgod.com is left out — byte-identical page for any URL; the verifier proves those through Cru's API. ~3 minutes, network.",
@ -306,7 +315,11 @@
"check:gallery-embed:live-cors": "the Research Gallery on a foreign page under the LIVE static layer's CORS rule (Access-Control-Allow-Origin on files inside the tree's folders, NOT on the root manifest.json, measured on nuxtbase): it must still boot every tab, by reading the CORS-readable manifest copy generate-tree writes beside its bundle.",
"check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.",
"check:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.",
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test."
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test.",
"check:english-leaks": "A translated key must not render in English. rega's method: a string is a LEAK when the catalogue has a DIFFERENT value for the locale and the page still shows the English one - a render/wiring fault, not a missing translation. Third-party attributions, data names, placeholder-only strings and the prose this bundle deliberately keeps in English are all excluded, the last by simply not being in the catalogue. Scoped to the LEAF on purpose: six keys were reported leaking in the gallery and the leaf is clean in ar/zh/ru/hi - the gallery builds its iframe src with no lang parameter, so the embed never learns the locale. Embedder defect, not a bundle one.",
"check:basemap-labels": "the BASEMAP's place names follow the reader's language on BOTH engines (library/maps/localize-labels.js): reads the RENDERED country labels at ?lang=en and ?lang=ru — Mapbox via the Standard basemap's place-labels featureset, MapLibre via each symbol's evaluated text — and requires them to flip. Serves this checkout on an OS-assigned port; --dir <tree> for a negative control. Negative-tested: a bundle built before 319064a1 FAILS on both engines.",
"check:basemap-labels": "the BASEMAP's place names follow the reader's language on BOTH engines (library/maps/localize-labels.js): reads the RENDERED country labels at ?lang=en and ?lang=ru — Mapbox via the Standard basemap's place-labels featureset, MapLibre via each symbol's evaluated text — and requires them to flip. Serves this checkout on an OS-assigned port; --dir <tree> for a negative control. Negative-tested: a bundle built before 319064a1 FAILS on both engines.",
"check:experimental-shelf": "Every Experimental-folder bundle, leaf + Gallery tab, mapbox + maplibre: mounts with content, 0 errors / failed requests, maps load and DRAW their own data. Self-tests on a deliberately broken bundle first. Run through internal/browser-slot.sh."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",

View file

@ -43,8 +43,14 @@ const config = computed(() => {
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
})
// The same ladder as the rest of the library (i18n/index.js detectLocale): the host's profile-config
// first, then ?lang= / ?locale= — without it the card was unreachable in any other language on the
// built tree, whose leaf pages all bake <html lang="en"> — then the page's lang.
const urlLang = (() => {
try { const q = new URLSearchParams(window.location.search); return q.get('lang') || q.get('locale') || '' } catch { return '' }
})()
const locale = computed(() => {
const raw = String(config.value.locale || (typeof document !== 'undefined' && document.documentElement.lang) || 'en')
const raw = String(config.value.locale || urlLang || (typeof document !== 'undefined' && document.documentElement.lang) || 'en')
const base = raw.split('-')[0].toLowerCase()
return STRINGS[raw] ? raw : (STRINGS[base] ? base : 'en')
})

View file

@ -46,11 +46,12 @@
* source of truth (the `polygons` ref), so a human clicking buttons and an agent
* calling methods can freely interleave.
*/
import { ref, shallowRef, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ref, shallowRef, reactive, computed, onMounted, onBeforeUnmount, nextTick, inject, watch } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { openPrintFrame } from '@map/composables/printInPlace.js'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { ensureRtlTextPlugin } from '@map/maps/rtl-text.js'
import { localizeBasemapLabels } from '@map/maps/localize-labels.js'
@ -102,6 +103,14 @@ const uiLocale = computed(() => {
const uiDir = computed(() => (uiLocale.value === 'ar' ? 'rtl' : 'ltr'))
const tr = (key, params) => translate(uiLocale.value, key, params)
// The shared controls (Share, Full screen) label themselves through the vue-i18n instance index.js
// installs, which reads only the PAGE language. Inside the Research Gallery the language arrives in
// profile-config instead, so keep that instance on the planner's own locale.
const appI18n = inject('appI18n', null)
watch(uiLocale, (l) => {
try { if (appI18n && appI18n.global) { if (typeof appI18n.global.locale === 'object') appI18n.global.locale.value = l; else appI18n.global.locale = l } } catch (_) { /* labels stay in the page language */ }
}, { immediate: true })
const INSTANCE_ID = props.instanceId || ('pwp-' + Math.random().toString(36).slice(2, 10))
const STORAGE_KEY = 'prayer-walk-planner:v1:' + INSTANCE_ID
@ -230,6 +239,9 @@ function setupMapLayers() {
map.value.addLayer({
id: 'pwp-polygons-label', type: 'symbol', source: POLY_SRC,
layout: {
// A font OpenFreeMap's glyph server has. With no text-font, MapLibre asks for its default
// (Open Sans / Arial Unicode MS), which OpenFreeMap 404s, and no zone title ever drew.
'text-font': ['Noto Sans Regular'],
'text-field': ['get', 'title'],
'text-size': 12,
'text-anchor': 'center'
@ -612,6 +624,8 @@ useShadowStyles(
'.pwp-body{position:relative;flex:1;min-height:0;display:flex}' +
'.pwp-map{position:relative;flex:1;min-width:0}' +
'.pwp-map-el{position:absolute;inset:0}' +
'.pwp-map-controls{position:absolute;top:10px;right:10px;z-index:4}' +
'.pwp[dir=rtl] .pwp-map-controls{right:auto;left:10px}' +
'.pwp-list{width:260px;flex-shrink:0;overflow-y:auto;background:#fff;border-left:1px solid #d1d4cd;padding:8px}' +
'.pwp-list-empty{padding:16px 10px;font-size:12px;color:#6d766e}' +
'.pwp-item{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;margin-bottom:4px;' +
@ -622,14 +636,26 @@ useShadowStyles(
'.pwp-item-title-input{flex:1;font-size:13px;font-weight:600;padding:2px 4px;border:1px solid #92b195;border-radius:4px}' +
'.pwp-item-del{border:none;background:transparent;color:#8a3b3b;cursor:pointer;font-size:14px;padding:2px 6px;border-radius:4px}' +
'.pwp-item-del:hover{background:rgba(138,59,59,.12)}' +
'@media (max-width:640px){.pwp-list{display:none}}'
'@media (max-width:640px){.pwp-list{display:none}}' +
// PHONE: ONE ROW, SCROLLED SIDEWAYS, instead of a toolbar that wrapped to three rows (139px of a
// 844px screen, measured 2026-09-25) above the map a walker is trying to read. Nothing is clipped:
// the row scrolls, labels never truncate. The two actions a walk needs, Draw and Print, lead the
// row so they are always on screen; the list-management buttons follow.
'@media (max-width:640px){' +
'.pwp-toolbar{flex-wrap:nowrap;overflow-x:auto;overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;scrollbar-width:none;padding:8px 10px;gap:6px}' +
'.pwp-toolbar::-webkit-scrollbar{display:none}' +
'.pwp-toolbar>*{flex:0 0 auto}' +
'.pwp-btn{white-space:nowrap;padding:8px 12px}' +
'.pwp-spacer{display:none}' +
'.pwp-draw{order:-2}.pwp-print{order:-1}' +
'}'
)
</script>
<template>
<div class="pwp" :dir="uiDir" :lang="uiLocale">
<div class="pwp-toolbar">
<button type="button" class="pwp-btn" :class="{ 'is-active': drawing }"
<button type="button" class="pwp-btn pwp-draw" :class="{ 'is-active': drawing }"
:aria-pressed="drawing" @click="onDrawClick">
{{ drawing ? tr('drawing') : tr('draw') }}
</button>
@ -649,7 +675,7 @@ useShadowStyles(
</button>
<div class="pwp-spacer"></div>
<span class="pwp-count">{{ tr('countSelected', { n: selectedCount, total: polygons.length }) }}</span>
<button type="button" class="pwp-btn is-accent" :disabled="selectedCount === 0"
<button type="button" class="pwp-btn is-accent pwp-print" :disabled="selectedCount === 0"
@click="onPrintClick" :aria-label="tr('printAria')">
{{ tr('printSelected') }}
</button>
@ -673,6 +699,11 @@ useShadowStyles(
<div class="pwp-body">
<div class="pwp-map">
<div ref="mapContainer" class="pwp-map-el"></div>
<!-- FULL SCREEN for the WHOLE planner (toolbar + map), not just the canvas: the shared
FullscreenButton requests fullscreen on this element's custom-element host, and falls
back to a CSS pseudo-fullscreen where the API does not exist (iOS Safari). A planner
embedded in a partner page or the gallery can take the whole screen for drawing. -->
<div class="pwp-map-controls"><FullscreenButton :map-container="mapContainer" /></div>
</div>
<div class="pwp-list">
<div v-if="polygons.length === 0" class="pwp-list-empty">

View file

@ -23,6 +23,7 @@
* token-less via the gated host hook). Lives inside the doxa-upg-neighbors merged profile
* as the "Flowers" view; reuses the granular per-PG coords from UPGN-MERGE.
*/
import { labelFont } from '../labelFont.js'
import { inject, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
@ -222,7 +223,7 @@ function paint() {
})
if (!m.getLayer(L_CORE_LBL)) m.addLayer({
id: L_CORE_LBL, type: 'symbol', source: SRC_CORE, minzoom: 2.2,
layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.4], 'text-anchor': 'top',
layout: { ...labelFont(m), 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.4], 'text-anchor': 'top',
'text-allow-overlap': false, 'text-optional': true },
paint: { 'text-color': C_RED_DK, 'text-halo-color': '#fff', 'text-halo-width': 1.4 }
})

View file

@ -17,6 +17,7 @@
* (this child's own <style> would be dropped by defineCustomElement).
*/
import { labelFont } from '../labelFont.js'
import { inject, ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
@ -197,7 +198,7 @@ function ensureLayers(m) {
})
if (!m.getLayer(L_GREEN_LBL)) m.addLayer({
id: L_GREEN_LBL, type: 'symbol', source: SRC_GREEN, minzoom: 3.4,
layout: { 'text-field': ['get', 'label'], 'text-size': 10, 'text-offset': [0, 1.1],
layout: { ...labelFont(m), 'text-field': ['get', 'label'], 'text-size': 10, 'text-offset': [0, 1.1],
'text-anchor': 'top', 'text-allow-overlap': false, 'text-optional': true },
paint: { 'text-color': '#0b5e33', 'text-halo-color': '#ffffff', 'text-halo-width': 1.3 }
})
@ -212,7 +213,7 @@ function ensureLayers(m) {
})
if (!m.getLayer(L_RED_LBL)) m.addLayer({
id: L_RED_LBL, type: 'symbol', source: SRC_RED, minzoom: 2.4,
layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.2],
layout: { ...labelFont(m), 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.2],
'text-anchor': 'top', 'text-allow-overlap': false, 'text-optional': true },
paint: { 'text-color': COLORS.RED_DK, 'text-halo-color': '#ffffff', 'text-halo-width': 1.4 }
})

View file

@ -0,0 +1,17 @@
/**
* labelFont.js — a text-font the map's own glyph server actually has.
*
* A symbol layer with no text-font asks for the engine default ("Open Sans Regular, Arial Unicode MS
* Regular"), and the bold labels here named Mapbox's "DIN Offc Pro Bold". Mapbox serves both.
* OpenFreeMap, the basemap under provider=maplibre, serves neither: measured, the glyph request is
* a 404 and the labels never draw. So under MapLibre the labels use Noto Sans, which it does serve;
* under Mapbox nothing changes.
*
* Spread into a layer's layout: layout: { 'text-field': …, ...labelFont(m) }
*/
export function labelFont(map, bold = false) {
const ml = typeof window !== 'undefined' && window.maplibregl
const isMaplibre = !!(ml && ml.Map && map instanceof ml.Map)
if (isMaplibre) return { 'text-font': [bold ? 'Noto Sans Bold' : 'Noto Sans Regular'] }
return bold ? { 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'] } : {}
}

View file

@ -34,6 +34,7 @@
* "dataUrl":"<reached-neighbors.json>", "instanceId":"..." }
*/
import { labelFont } from './labelFont.js'
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
@ -318,7 +319,7 @@ function paintAll() {
})
if (!m.getLayer(L_CL_LABEL)) m.addLayer({
id: L_CL_LABEL, type: 'symbol', source: SRC_CL, minzoom: 2.6,
layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.3],
layout: { ...labelFont(m), 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.3],
'text-anchor': 'top', 'text-allow-overlap': false, 'text-optional': true },
paint: { 'text-color': C_RED_DK, 'text-halo-color': '#ffffff', 'text-halo-width': 1.4 }
})

View file

@ -43,6 +43,7 @@
* }
*/
import { labelFont } from './labelFont.js'
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
@ -308,7 +309,7 @@ function syncLayers() {
id: L_SENDER_LABEL, type: 'symbol', source: SRC_SENDERS,
minzoom: 3.4,
layout: {
'text-field': ['get', 'name'], 'text-size': 10,
...labelFont(m), 'text-field': ['get', 'name'], 'text-size': 10,
'text-offset': [0, 1.1], 'text-anchor': 'top', 'text-allow-overlap': false
},
paint: { 'text-color': '#0b5e33', 'text-halo-color': '#ffffff', 'text-halo-width': 1.3 }
@ -329,7 +330,7 @@ function syncLayers() {
m.addLayer({
id: L_TARGET_LABEL, type: 'symbol', source: SRC_TARGET,
layout: {
'text-field': ['get', 'name'], 'text-size': 13, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'],
'text-field': ['get', 'name'], 'text-size': 13, ...labelFont(m, true),
'text-offset': [0, -1.4], 'text-anchor': 'bottom', 'text-allow-overlap': true
},
paint: { 'text-color': '#8a3800', 'text-halo-color': '#ffffff', 'text-halo-width': 1.6 }

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>wall-of-people-groups (dev)</title>
<link rel="icon" href="data:,">
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{height:100%;background:#f3f3f1}wall-of-people-groups{position:fixed;inset:0;display:block}</style>
</head>
<body>
<wall-of-people-groups profile-config='{"profile":"wall-of-people-groups"}'></wall-of-people-groups>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,28 @@
/**
* wall-of-people-groups / index.js — bundle entry (emits wall-of-people-groups.js), defines
* <wall-of-people-groups>. The standard ProfileLoader shape: the glob captures this folder's one
* profile; its components live in src/. The wall's components read the store as this.$store (the
* original app's global property), so it is created here, per element, on the element's Pinia.
*/
import { defineCustomElement } from 'vue'
import { createPinia } from 'pinia'
import ProfileLoader from '@map/ProfileLoader.vue'
import { createAppI18n } from '@map/i18n/index.js'
import { usePeopleGroupsStore } from './src/stores/peopleGroupsStore.js'
const profileModules = import.meta.glob('./*.vue')
const El = defineCustomElement(ProfileLoader, {
configureApp(app) {
const pinia = createPinia()
app.use(pinia)
const i18n = createAppI18n()
app.use(i18n)
app.provide('appI18n', i18n)
app.provide('profileModules', profileModules)
const store = usePeopleGroupsStore(pinia)
store.loadFromLocalStorage()
app.config.globalProperties.$store = store
}
})
if (typeof customElements !== 'undefined' && !customElements.get('wall-of-people-groups')) customElements.define('wall-of-people-groups', El)

View file

@ -0,0 +1,207 @@
<template>
<div v-if="visible" class="dialog-overlay" @click="closeDialog">
<div class="dialog-container" @click.stop>
<!-- Dialog Header -->
<div class="dialog-header">
<h2>People Group Chats</h2>
<button class="close-btn" @click="closeDialog">✕</button>
</div>
<!-- People Group Image (Instagram style) -->
<div class="group-image-container">
<img
:src="getImageUrl()"
:alt="group?.name || 'People Group'"
class="group-image"
@error="handleImageError"
/>
</div>
<div class="group-name-dialog">{{ group?.name || 'Unknown Group' }}</div>
<!-- People Group Info -->
<div class="dialog-content">
<div class="group-info">
<div class="info-row">
<strong>Status:</strong> {{ group?.status || 'Unknown' }}
</div>
<div class="info-row">
<strong>ID:</strong> {{ group?.ID || 'N/A' }}
</div>
<div class="info-row" v-if="group?.lat && group?.lng">
<strong>Location:</strong> {{ group.lat }}, {{ group.lng }}
</div>
<div class="info-row prayer-stat">
<span class="heart-icon">❤️</span>
<strong>{{ prayerCount }}</strong> {{ prayerCount === 1 ? 'person has' : 'people have' }} prayed
</div>
</div>
<!-- Prayer Button -->
<div class="prayer-action">
<button
class="prayer-btn"
@click="addPrayer"
:class="{ active: justPrayed }"
>
{{ justPrayed ? '✓ You prayed!' : '🙏 Pray for this group' }}
</button>
</div>
<!-- Comments Section -->
<div class="comments-section">
<h3>Prayers & Comments</h3>
<!-- Comment List -->
<div class="comments-list">
<div
v-if="comments.length === 0"
class="no-comments"
>
No prayers or comments yet. Be the first!
</div>
<div
v-for="comment in comments"
:key="comment.id"
class="comment-item"
>
<div class="comment-header">
<span class="comment-icon">💭</span>
<span class="comment-date">{{ formatDate(comment.timestamp) }}</span>
</div>
<div class="comment-text">{{ comment.text }}</div>
</div>
</div>
<!-- Add Comment Form -->
<div class="add-comment">
<textarea
v-model="newComment"
placeholder="Share your prayer or thoughts..."
rows="3"
maxlength="500"
></textarea>
<div class="comment-actions">
<span class="char-count">{{ newComment.length }}/500</span>
<button
@click="submitComment"
:disabled="!newComment.trim()"
class="submit-btn"
>
Post Comment
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
// No third-party placeholder service: a missing photo is drawn inline.
const NO_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400'%3E%3Crect width='100%25' height='100%25' fill='%23f3f3f1'/%3E%3Ctext x='50%25' y='50%25' fill='%236d766e' font-family='sans-serif' font-size='22' text-anchor='middle' dominant-baseline='middle'%3ENo image available%3C/text%3E%3C/svg%3E"
// Comments and Prayers Dialog Component
export default {
name: 'CommentsDialog',
props: {
group: {
type: Object,
default: null
},
visible: {
type: Boolean,
default: false
}
},
data() {
return {
newComment: '',
justPrayed: false,
imageError: false
};
},
computed: {
prayerCount() {
if (!this.group || !this.$store) return 0;
// Access the prayers state directly
return this.$store.prayers?.[this.group.ID] || 0;
},
comments() {
if (!this.group || !this.$store) return [];
// Access the comments state directly
return this.$store.comments?.[this.group.ID] || [];
}
},
methods: {
closeDialog() {
this.$emit('close');
this.newComment = '';
this.justPrayed = false;
},
addPrayer() {
if (this.group && this.$store) {
this.$store.addPrayer(this.group.ID);
this.justPrayed = true;
// Reset after 2 seconds
setTimeout(() => {
this.justPrayed = false;
}, 2000);
}
},
submitComment() {
if (this.newComment.trim() && this.group && this.$store) {
this.$store.addComment(
this.group.ID,
this.newComment.trim()
);
this.newComment = '';
}
},
formatDate(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
},
getImageUrl() {
if (this.imageError) {
return NO_IMAGE;
}
// The card's own photo (the live API's image_url); uupg.doxa.life no longer answers.
return this.group?.imageUrl || NO_IMAGE;
},
handleImageError(event) {
this.imageError = true;
event.target.src = NO_IMAGE;
}
},
watch: {
visible(newVal) {
if (newVal) {
// Reset state when dialog opens
this.justPrayed = false;
}
}
}
};
</script>

View file

@ -0,0 +1,150 @@
<template>
<div
class="people-group-card"
:style="{ transform: 'translate(' + x + 'px, ' + y + 'px)' }"
>
<!-- Header with People Group Name -->
<div class="card-header">
<h3 class="group-name">
<a
v-if="hasJoshuaProjectLink()"
:href="getJoshuaProjectUrl()"
target="_blank"
rel="noopener noreferrer"
class="group-name-link"
>
{{ group.name || group.post_title || 'Unknown Group' }}
</a>
<span v-else class="group-name-no-link" title="This people group doesn't have a PeopleID3">
{{ group.name || group.post_title || 'Unknown Group' }}
</span>
</h3>
<span class="group-status">{{ group.status || 'Status Unknown' }}</span>
</div>
<!-- Image Placeholder -->
<div class="card-image" @click="$emit('view-detail', group)">
<img
:src="getImageUrl()"
:alt="group.name"
@error="handleImageError"
/>
</div>
<!-- Action Buttons (Instagram style) -->
<div class="card-actions">
<button
class="action-btn heart-btn"
@click="handlePrayer"
:class="{ active: hasPrayed }"
>
<span class="icon">{{ hasPrayed ? '❤️' : '🤍' }}</span>
</button>
<button
class="action-btn comment-btn"
@click="$emit('open-comments', group)"
>
<span class="icon">💬</span>
</button>
<button
class="action-btn share-btn"
@click="handleShare"
>
<span class="icon">📤</span>
</button>
</div>
<!-- Prayer Count -->
<div class="card-footer">
<p class="prayer-count">
<strong>{{ prayerCount }}</strong> {{ prayerCount === 1 ? 'person' : 'people' }}
prayed that they would receive God's love and the love of a missionary
</p>
<button
class="view-comments-btn"
@click="$emit('open-comments', group)"
>
View prayers and comments
</button>
</div>
</div>
</template>
<script>
// No third-party placeholder service: a missing photo is drawn inline.
const NO_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400'%3E%3Crect width='100%25' height='100%25' fill='%23f3f3f1'/%3E%3Ctext x='50%25' y='50%25' fill='%236d766e' font-family='sans-serif' font-size='22' text-anchor='middle' dominant-baseline='middle'%3ENo image available%3C/text%3E%3C/svg%3E"
// People Group Card Component - Instagram style
export default {
name: 'PeopleGroupCard',
props: {
group: {
type: Object,
required: true
},
x: {
type: Number,
default: 0
},
y: {
type: Number,
default: 0
}
},
data() {
return {
imageError: false,
hasPrayed: false
};
},
computed: {
prayerCount() {
// Access the prayers state directly
const groupId = this.group.id || this.group.PeopleID3;
return this.$store?.prayers?.[groupId] || 0;
}
},
methods: {
hasJoshuaProjectLink() {
const peopleID3 = this.group.peopleID3 || this.group.PeopleID3;
return peopleID3 && peopleID3.trim() !== '';
},
getJoshuaProjectUrl() {
const peopleID3 = this.group.peopleID3 || this.group.PeopleID3;
if (!peopleID3 || peopleID3.trim() === '') {
return null;
}
return `https://joshuaproject.net/people_groups/${peopleID3}`;
},
getImageUrl() {
if (this.imageError) {
return NO_IMAGE;
}
// The live API's image_url; uupg.doxa.life no longer answers.
return this.group.imageUrl || NO_IMAGE;
},
handleImageError(event) {
this.imageError = true;
event.target.src = NO_IMAGE;
},
handlePrayer() {
this.hasPrayed = !this.hasPrayed;
if (this.hasPrayed) {
this.$emit('add-prayer', this.group.id || this.group.PeopleID3);
}
},
handleShare() {
alert('Sharing functionality coming later!');
}
}
};
</script>

View file

@ -0,0 +1,509 @@
<template>
<div class="wall-container">
<div class="wall-header">
<h1>Wall of People Groups</h1>
<div class="wall-controls">
<button @click="toggleOrientation" class="control-btn orientation-btn">
<span class="btn-label">{{ isLandscape ? '📱 Switch to Portrait' : '🖼️ Switch to Landscape' }}</span>
<span class="current-mode">Current: {{ isLandscape ? 'Landscape (4ft×3ft)' : 'Portrait (3ft×4ft)' }}</span>
</button>
<button @click="resetZoom" class="control-btn">Reset Zoom</button>
<span class="group-count">{{ totalGroups }} Groups Loaded</span>
</div>
</div>
<div ref="canvas" class="wall-canvas"></div>
<div v-if="loading" class="loading-overlay">
<div class="spinner"></div>
<p>Loading people groups...</p>
</div>
</div>
</template>
<script>
// Wall of People Groups - D3 Zoomable Whiteboard
export default {
name: 'PeopleGroupsWall',
data() {
return {
svg: null,
zoom: null,
cardWidth: 50,
cardHeight: 70,
cardSpacing: 8, // Increased horizontal spacing (was 2)
cardVerticalSpacing: 3, // Separate vertical spacing
canvasWidth: 0,
canvasHeight: 0,
isLandscape: true
};
},
computed: {
loading() {
return this.$store?.loading || false;
},
totalGroups() {
return this.$store?.totalGroups || 0;
},
peopleGroups() {
return this.$store?.peopleGroups || [];
}
},
mounted() {
this.initializeCanvas();
this.fetchData();
},
methods: {
async fetchData() {
console.log('📊 Wall component fetching data...');
if (this.$store) {
await this.$store.fetchPeopleGroups();
console.log('🎨 Data fetched, rendering cards...');
this.renderCards();
} else {
console.error('❌ Store not available!');
}
},
initializeCanvas() {
// Set dimensions based on orientation
if (this.isLandscape) {
// 4ft x 3ft aspect ratio = 4:3 ratio (width:height) - LANDSCAPE
this.canvasWidth = 4000; // 4ft wide
this.canvasHeight = 3000; // 3ft tall
} else {
// 3ft x 4ft aspect ratio = 3:4 ratio (width:height) - PORTRAIT
this.canvasWidth = 3000; // 3ft wide
this.canvasHeight = 4000; // 4ft tall
}
const container = d3.select(this.$refs.canvas);
// Create SVG
this.svg = container
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${this.canvasWidth} ${this.canvasHeight}`)
.attr('preserveAspectRatio', 'xMidYMid meet');
// Create zoom behavior - start zoomed out to see everything
this.zoom = d3.zoom()
.scaleExtent([0.1, 20])
.filter(function(event) {
// Allow zoom/pan except when trying to select text
// Prevent zoom/pan when clicking on text elements with user-select enabled
const target = event.target;
// Allow text selection on text elements
if (target.tagName === 'text' || target.tagName === 'tspan') {
return false;
}
// Allow normal zoom/pan for everything else
return !event.ctrlKey && !event.button;
})
.on('zoom', (event) => {
this.svg.select('.cards-container')
.attr('transform', event.transform);
});
this.svg.call(this.zoom);
// Add background
this.svg.append('rect')
.attr('width', this.canvasWidth)
.attr('height', this.canvasHeight)
.attr('fill', '#fafafa');
// Create container for cards
this.svg.append('g')
.attr('class', 'cards-container');
},
renderCards() {
console.log('🎨 Rendering cards...');
console.log('People groups available:', this.peopleGroups?.length || 0);
if (!this.peopleGroups || this.peopleGroups.length === 0) {
console.warn('⚠️ No people groups to render');
return;
}
const cardsContainer = this.svg.select('.cards-container');
const totalGroups = this.peopleGroups.length;
// Calculate optimal grid layout based on 4:3 aspect ratio (LANDSCAPE)
// Canvas is 4000x3000 (4:3 ratio - 4ft wide × 3ft tall)
// We need to fit totalGroups cards
// Card aspect ratio (roughly portrait, like Instagram)
const cardAspectRatio = this.cardWidth / this.cardHeight; // ~0.71
// Canvas aspect ratio
const canvasAspectRatio = this.canvasWidth / this.canvasHeight; // 1.33 (4:3)
// Calculate columns and rows to maintain aspect ratio and fit all cards
// cols / rows should approximate canvasAspectRatio
// cols * rows should equal totalGroups
const cols = Math.ceil(Math.sqrt(totalGroups * canvasAspectRatio));
const rows = Math.ceil(totalGroups / cols);
console.log(`📐 Grid Layout: ${cols} columns × ${rows} rows = ${cols * rows} slots for ${totalGroups} groups`);
console.log(`📏 Canvas: ${this.canvasWidth}×${this.canvasHeight} (aspect: ${canvasAspectRatio.toFixed(2)})`);
// Calculate card size to fit the grid
const availableWidth = this.canvasWidth - (this.cardSpacing * (cols + 1));
const availableHeight = this.canvasHeight - (this.cardSpacing * (rows + 1));
const calculatedCardWidth = availableWidth / cols;
const calculatedCardHeight = availableHeight / rows;
// Use the smaller of the two to ensure cards fit
this.cardWidth = Math.min(calculatedCardWidth, calculatedCardHeight * 0.7);
this.cardHeight = this.cardWidth / 0.7; // Maintain card aspect ratio
console.log(`🎴 Card size: ${this.cardWidth.toFixed(1)}×${this.cardHeight.toFixed(1)}`);
console.log('📦 Sample group data:', this.peopleGroups[0]);
const totalWidth = this.cardWidth + this.cardSpacing;
const totalHeight = this.cardHeight + this.cardVerticalSpacing;
// Create card groups with better spacing
const cardGroups = cardsContainer
.selectAll('.card-group')
.data(this.peopleGroups)
.enter()
.append('g')
.attr('class', 'card-group')
.attr('transform', (d, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = col * totalWidth + this.cardSpacing;
const y = row * totalHeight + this.cardVerticalSpacing;
return `translate(${x}, ${y})`;
});
// Add card background
cardGroups
.append('rect')
.attr('width', this.cardWidth)
.attr('height', this.cardHeight)
.attr('fill', 'white')
.attr('stroke', '#dbdbdb')
.attr('stroke-width', 0.5)
.attr('rx', 0)
.style('cursor', 'pointer')
.style('pointer-events', 'none'); // Don't block text selection
// Calculate proportional font sizes - smaller for names
const nameFontSize = Math.max(3.5, this.cardWidth * 0.07);
const statusFontSize = Math.max(3, this.cardWidth * 0.055);
const iconSize = Math.max(8, this.cardWidth * 0.12);
const prayerFontSize = Math.max(3.5, this.cardWidth * 0.065);
const cardWidth = this.cardWidth;
const cardHeight = this.cardHeight;
// Add people group name - NO TRUNCATION, smaller font, with clickable link
cardGroups.each(function(d) {
const group = d3.select(this);
const peopleID3 = d.peopleID3 || d.PeopleID3;
const name = d.name || d.post_title || 'Unknown Group';
let nameElement;
// If has peopleID3, wrap in a link
if (peopleID3 && peopleID3.trim() !== '') {
const link = group.append('a')
.attr('href', `https://joshuaproject.net/people_groups/${peopleID3}`)
.attr('target', '_blank')
.attr('rel', 'noopener noreferrer')
.style('cursor', 'pointer');
nameElement = link.append('text')
.attr('x', cardWidth / 2)
.attr('y', cardHeight * 0.06)
.attr('text-anchor', 'middle')
.attr('font-size', nameFontSize)
.attr('font-weight', 'bold')
.attr('fill', '#0095f6') // Blue for links
.attr('text-decoration', 'underline')
.style('user-select', 'text')
.style('pointer-events', 'all');
} else {
// No link, just text
nameElement = group.append('text')
.attr('x', cardWidth / 2)
.attr('y', cardHeight * 0.06)
.attr('text-anchor', 'middle')
.attr('font-size', nameFontSize)
.attr('font-weight', 'bold')
.attr('fill', '#262626') // Dark gray for non-links
.style('user-select', 'text')
.style('pointer-events', 'all');
}
// Try to fit in one line first
nameElement.text(name);
// If too long, wrap to two lines
if (nameElement.node().getComputedTextLength() > cardWidth * 0.95) {
nameElement.text('');
const words = name.split(' ');
const mid = Math.ceil(words.length / 2);
const line1 = words.slice(0, mid).join(' ');
const line2 = words.slice(mid).join(' ');
nameElement.append('tspan')
.attr('x', cardWidth / 2)
.attr('dy', 0)
.text(line1);
nameElement.append('tspan')
.attr('x', cardWidth / 2)
.attr('dy', nameFontSize * 1.1)
.text(line2);
}
});
// Add status (moved down to accommodate 2-line names)
cardGroups
.append('text')
.attr('x', cardWidth / 2)
.attr('y', this.cardHeight * 0.14)
.attr('text-anchor', 'middle')
.attr('font-size', statusFontSize)
.attr('fill', '#8e8e8e')
.style('user-select', 'text')
.style('pointer-events', 'all')
.text(d => d.status || '');
// Add image or placeholder - FULL WIDTH like Instagram
const imageY = this.cardHeight * 0.18;
const imageHeight = this.cardHeight * 0.50; // Larger image
const imageWidth = this.cardWidth; // FULL WIDTH
// Background rectangle for image area
cardGroups
.append('rect')
.attr('x', 0)
.attr('y', imageY)
.attr('width', imageWidth)
.attr('height', imageHeight)
.attr('fill', '#fafafa')
.attr('stroke', '#efefef')
.attr('stroke-width', 0.5)
.attr('rx', 0); // NO rounded corners
// Add images or placeholders
cardGroups.each(function(d) {
const group = d3.select(this);
if (d.imageUrl) {
// Add SVG image element with proper aspect ratio
group
.append('image')
.attr('x', 0)
.attr('y', imageY)
.attr('width', imageWidth)
.attr('height', imageHeight)
.attr('href', d.imageUrl)
.attr('preserveAspectRatio', 'xMidYMid slice')
.style('pointer-events', 'none')
.on('error', function() {
console.log('Image failed to load:', d.imageUrl);
});
} else {
// Add placeholder icon/text if no image URL
group
.append('text')
.attr('x', cardWidth / 2)
.attr('y', imageY + imageHeight / 2)
.attr('text-anchor', 'middle')
.attr('font-size', iconSize * 1.5)
.attr('fill', '#c7c7c7')
.text('📷')
.style('pointer-events', 'none');
}
});
// Add action buttons row with FontAwesome icons - Instagram style
const buttonY = this.cardHeight * 0.72;
const buttonSize = iconSize * 0.7; // Make buttons smaller (was iconSize)
const buttonSpacing = buttonSize * 1.6; // Tighter spacing
const totalButtonWidth = (buttonSize * 3) + (buttonSpacing * 2);
const startX = (cardWidth - totalButtonWidth) / 2; // Center the button group
// Heart button (using foreignObject for FontAwesome)
const heartButton = cardGroups
.append('foreignObject')
.attr('x', startX)
.attr('y', buttonY)
.attr('width', buttonSize)
.attr('height', buttonSize)
.style('cursor', 'pointer')
.style('overflow', 'visible')
.on('click', (event, d) => {
event.stopPropagation();
this.openPrayerDialog(d);
});
heartButton
.append('xhtml:div')
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
.style('width', '100%')
.style('height', '100%')
.style('display', 'flex')
.style('align-items', 'center')
.style('justify-content', 'center')
.html(function(d) {
// Combine API prayer count with any local session prayers
const apiCount = Number(d.peoplePraying || 0);
const localCount = self.$store?.prayers?.[d.ID] || 0;
const prayerCount = apiCount + localCount;
const iconClass = prayerCount > 0 ? 'fas fa-heart' : 'far fa-heart';
const iconColor = prayerCount > 0 ? '#ed4956' : '#262626';
return '<i class="' + iconClass + '" style="font-size: ' + buttonSize + 'px; color: ' + iconColor + ';"></i>';
});
// Comment button
const commentButton = cardGroups
.append('foreignObject')
.attr('x', startX + buttonSpacing)
.attr('y', buttonY)
.attr('width', buttonSize)
.attr('height', buttonSize)
.style('cursor', 'pointer')
.style('overflow', 'visible')
.on('click', (event, d) => {
event.stopPropagation();
this.openComments(d);
});
commentButton
.append('xhtml:div')
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
.style('width', '100%')
.style('height', '100%')
.style('display', 'flex')
.style('align-items', 'center')
.style('justify-content', 'center')
.html('<i class="far fa-comment" style="font-size: ' + buttonSize + 'px; color: #262626;"></i>');
// Share button
const shareButton = cardGroups
.append('foreignObject')
.attr('x', startX + (buttonSpacing * 2))
.attr('y', buttonY)
.attr('width', buttonSize)
.attr('height', buttonSize)
.style('cursor', 'pointer')
.style('overflow', 'visible')
.on('click', (event, d) => {
event.stopPropagation();
alert('Sharing functionality coming later!');
});
shareButton
.append('xhtml:div')
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
.style('width', '100%')
.style('height', '100%')
.style('display', 'flex')
.style('align-items', 'center')
.style('justify-content', 'center')
.html('<i class="far fa-paper-plane" style="font-size: ' + buttonSize + 'px; color: #262626;"></i>');
// Add prayer count text
cardGroups
.append('text')
.attr('x', startX)
.attr('y', buttonY + buttonSize + prayerFontSize + 2)
.attr('text-anchor', 'start')
.attr('font-size', prayerFontSize)
.attr('fill', '#262626')
.attr('font-weight', '600')
.attr('class', 'prayer-text')
.style('user-select', 'text')
.style('pointer-events', 'all')
.text(d => {
// Combine API prayer count with any local session prayers
const apiCount = Number(d.peoplePraying || 0);
const localCount = this.$store?.prayers?.[d.ID] || 0;
const count = apiCount + localCount;
if (count === 0) return '0 prayed';
return `${count} ${count === 1 ? 'person' : 'people'} prayed`;
});
// Add "View prayers" button
cardGroups
.append('text')
.attr('x', startX)
.attr('y', buttonY + buttonSize + (prayerFontSize * 2) + 4)
.attr('text-anchor', 'start')
.attr('font-size', prayerFontSize)
.attr('fill', '#8e8e8e')
.attr('cursor', 'pointer')
.attr('class', 'view-comments-text')
.style('user-select', 'none')
.style('pointer-events', 'all')
.text('View prayers')
.on('click', (event, d) => {
event.stopPropagation();
this.openComments(d);
});
console.log(`✅ Rendered ${totalGroups} cards in ${cols}×${rows} grid`);
},
handlePrayer(group) {
// This method is deprecated - now using openPrayerDialog
this.openPrayerDialog(group);
},
openPrayerDialog(group) {
this.$emit('open-prayer', group);
},
openComments(group) {
this.$emit('open-comments', group);
},
truncateText(text, maxLength) {
if (!text) return '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
},
resetZoom() {
this.svg.transition()
.duration(750)
.call(this.zoom.transform, d3.zoomIdentity);
},
toggleOrientation() {
this.isLandscape = !this.isLandscape;
// Clear existing canvas
d3.select(this.$refs.canvas).selectAll('*').remove();
// Reinitialize with new orientation
this.initializeCanvas();
// Re-render cards
if (this.peopleGroups && this.peopleGroups.length > 0) {
this.renderCards();
}
console.log(`📐 Switched to ${this.isLandscape ? 'LANDSCAPE (4ft×3ft)' : 'PORTRAIT (3ft×4ft)'}`);
},
printWall() {
alert('Print functionality coming later!');
}
}
};
</script>

View file

@ -0,0 +1,157 @@
<template>
<div v-if="isOpen" class="dialog-overlay" @click.self="closeDialog">
<div class="dialog-container prayer-dialog">
<div class="dialog-header">
<h2>Prayer Tools</h2>
<button @click="closeDialog" class="close-btn">&times;</button>
</div>
<div class="group-image-container">
<img
:src="getImageUrl()"
:alt="group?.name || 'People Group'"
@error="handleImageError"
/>
</div>
<div class="group-name-dialog">
{{ group?.name || 'Unknown' }}
</div>
<div class="dialog-content">
<div class="group-info">
<div class="info-row">
<strong>Status:</strong> {{ group?.status || 'unengaged' }}
</div>
</div>
<div v-if="!hasPrayed" class="prayer-prompt">
<h3>Are you praying right now for this people group?</h3>
<button @click="confirmPrayer" class="prayer-confirm-btn">
<i class="far fa-heart"></i>
Yes, I'm Praying
</button>
</div>
<div v-else class="prayer-confirmed">
<div class="confirmed-message">
<i class="fas fa-heart" style="color: #ed4956;"></i>
<span>Thank you for praying!</span>
</div>
<div class="prayer-input-section">
<h4>Share your prayer (optional)</h4>
<textarea
v-model="prayerText"
placeholder="Type your prayer here..."
rows="4"
></textarea>
<button
@click="sendPrayer"
:disabled="!prayerText.trim()"
class="send-prayer-btn"
>
Send Prayer
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
// No third-party placeholder service: a missing photo is drawn inline.
const NO_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400'%3E%3Crect width='100%25' height='100%25' fill='%23f3f3f1'/%3E%3Ctext x='50%25' y='50%25' fill='%236d766e' font-family='sans-serif' font-size='22' text-anchor='middle' dominant-baseline='middle'%3ENo image available%3C/text%3E%3C/svg%3E"
export default {
name: 'PrayerDialog',
props: {
isOpen: {
type: Boolean,
default: false
},
group: {
type: Object,
default: null
}
},
data() {
return {
hasPrayed: false,
prayerText: '',
imageError: false
};
},
watch: {
isOpen(newVal) {
if (newVal) {
// Check if user already prayed for this group
// Access the prayers state directly
const prayerCount = this.$store?.prayers?.[this.group?.ID] || 0;
this.hasPrayed = prayerCount > 0;
this.prayerText = '';
this.imageError = false;
}
}
},
methods: {
closeDialog() {
this.$emit('close');
},
confirmPrayer() {
if (this.group?.ID) {
this.$store.addPrayer(this.group.ID);
this.hasPrayed = true;
// Emit event to update the card's heart icon
this.$emit('prayer-confirmed', this.group.ID);
}
},
sendPrayer() {
if (this.prayerText.trim() && this.group?.ID) {
// Add prayer to comments as a special type
const pseudonym = this.generatePseudonym();
const prayerComment = `🙏 ${pseudonym} prayed: ${this.prayerText.trim()}`;
this.$store.addComment(this.group.ID, prayerComment);
this.prayerText = '';
// Close dialog after sending
setTimeout(() => {
this.closeDialog();
}, 500);
}
},
generatePseudonym() {
const adjectives = ['Faithful', 'Devoted', 'Humble', 'Earnest', 'Hopeful', 'Grateful', 'Compassionate'];
const nouns = ['Servant', 'Believer', 'Intercessor', 'Warrior', 'Witness', 'Disciple', 'Friend'];
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const num = Math.floor(Math.random() * 999) + 1;
return `${adj} ${noun} ${num}`;
},
getImageUrl() {
if (this.imageError) {
return NO_IMAGE;
}
// The same photo the card shows (the live API's image_url). This used to build a URL on
// uupg.doxa.life, which no longer answers, so every dialog fell back to "No image".
return this.group?.imageUrl || NO_IMAGE;
},
handleImageError(event) {
this.imageError = true;
event.target.src = NO_IMAGE;
}
}
};
</script>

View file

@ -0,0 +1,83 @@
<template>
<div v-if="sources.length > 1">
<div class="source-selector">
<div class="source-header">
<h3>📊 Data Source</h3>
<span class="current-source-label">Current: {{ currentSource?.name }}</span>
</div>
<div class="source-buttons">
<button
v-for="source in availableSources"
:key="source.id"
@click="selectSource(source.id)"
:class="['source-btn', { active: dataSource === source.id }]"
>
<span class="source-icon">{{ getSourceIcon(source.id) }}</span>
<span class="source-name">{{ source.name }}</span>
<span v-if="dataSource === source.id" class="active-indicator">✓</span>
</button>
</div>
<div class="source-info">
<p v-if="currentSource">
<strong>Source URL:</strong><br>
<code>{{ currentSource.url }}</code>
</p>
<p v-if="dataSource === 'CSV'" class="csv-info">
<i class="fas fa-info-circle"></i> CSV includes ~1,439 groups with images from Joshua Project
</p>
<p v-if="dataSource === 'DOXA_API'" class="api-info">
<i class="fas fa-info-circle"></i> WordPress API — descriptions & photos, no prayer data
</p>
<p v-if="dataSource === 'PRAY_API'" class="api-info">
<i class="fas fa-info-circle"></i> Live prayer data — people praying, committed workers, adoption, images
</p>
</div>
</div>
</div>
</template>
<script>
// Source Selector Component - Switch between API and CSV data sources
export default {
name: 'SourceSelector',
computed: {
dataSource() {
return this.$store?.dataSource || 'API';
},
availableSources() {
return this.$store?.availableSources || [];
},
currentSource() {
return this.$store?.currentSource || null;
}
},
methods: {
selectSource(sourceId) {
console.log('🔄 Switching to source:', sourceId);
if (this.$store) {
this.$store.setDataSource(sourceId);
}
},
getSourceIcon(sourceId) {
switch(sourceId) {
case 'PRAY_API':
return '🙏';
case 'DOXA_API':
return '🌐';
case 'API':
return '🌐';
case 'CSV':
return '📄';
default:
return '📊';
}
}
}
};
</script>

View file

@ -0,0 +1,635 @@
// People Groups Store - Manages data from the Doxa prayer API
// Moved 2026-09-25 from the wall-of-people-groups no-build SPA: Pinia now comes from the package
// (it was the Pinia CDN global), localStorage keys carry a 'wall-pg:' prefix so an embedded wall
// cannot collide with the host page's own keys, and the source list is the one live API.
import { defineStore } from 'pinia'
const LS = (k) => 'wall-pg:' + k
export const usePeopleGroupsStore = (pinia) => {
return defineStore('peopleGroups', {
state: () => ({
peopleGroups: [],
loading: false,
error: null,
prayers: {}, // Track prayers per people group ID
comments: {}, // Track comments per people group ID
dataSource: 'PRAY_API', // 'PRAY_API', 'DOXA_API', or 'CSV'
// The Doxa WordPress endpoint (uupg.doxa.life) no longer answers, and the CSV fallback was a
// 2.6 MB copy of what the live API already serves, so neither ships.
availableSources: [
{ id: 'PRAY_API', name: 'Prayer.Tools API (Live)', url: 'https://pray.doxa.life/api/people-groups/list', description: 'Live prayer data with images' }
]
}),
getters: {
totalGroups: (state) => state.peopleGroups.length,
getPrayerCount: (state) => (groupId) => {
return state.prayers[groupId] || 0;
},
getComments: (state) => (groupId) => {
return state.comments[groupId] || [];
},
currentSource: (state) => {
return state.availableSources.find(s => s.id === state.dataSource);
}
},
actions: {
async fetchPeopleGroups() {
this.loading = true;
this.error = null;
console.log(`🔄 Fetching people groups from ${this.dataSource}...`);
try {
if (this.dataSource === 'PRAY_API') {
await this.fetchFromPrayAPI();
} else if (this.dataSource === 'DOXA_API') {
await this.fetchFromAPI();
} else if (this.dataSource === 'CSV') {
await this.fetchFromCSV();
}
console.log(`✅ Successfully loaded ${this.peopleGroups.length} people groups from ${this.dataSource}!`);
console.log('First 3 groups:', this.peopleGroups.slice(0, 3));
} catch (error) {
console.error(`❌ Error fetching people groups from ${this.dataSource}:`, error);
this.error = error.message;
this.peopleGroups = [];
} finally {
this.loading = false;
}
},
async fetchFromPrayAPI() {
const source = this.availableSources.find(s => s.id === 'PRAY_API');
const apiUrl = source.url;
console.log('🙏 Prayer.Tools API URL:', apiUrl);
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Guard against HTML responses (bot protection, Nuxt SPA shell)
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('text/html')) {
throw new Error('Server returned HTML instead of JSON — the API endpoint may be blocked or unavailable');
}
const data = await response.json();
const posts = data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []);
if (posts.length === 0) {
console.error('Unexpected API response format:', data);
this.peopleGroups = [];
return;
}
// Placeholder for groups without photos
const noPhotoPlaceholder = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
this.peopleGroups = posts.map(group => {
// Extract labels from nested value/label objects
const regionLabel = group.wagf_region?.label || group.wagf_region || '';
const blockLabel = group.wagf_block?.label || group.wagf_block || '';
const countryLabel = group.country_code?.label || group.country_code || '';
const countryCode = group.country_code?.value || group.country_code || '';
const religionLabel = group.religion?.label || group.religion || '';
const religionCode = group.religion?.value || '';
const affinityLabel = group.rop1?.label || '';
// Image URL — API provides direct URLs, with regional fallbacks for no-photo groups
const hasPhoto = group.has_photo === true || group.has_photo === 'true' || group.has_photo === 1;
const imageUrl = group.image_url || noPhotoPlaceholder;
return {
// Standard fields
ID: group.slug || `pray-${Math.random().toString(36).slice(2)}`,
post_title: group.name || 'Unknown',
name: group.name || 'Unknown',
slug: group.slug || '',
status: '',
country: countryLabel,
countryCode: countryCode,
region: regionLabel,
wagfBlock: blockLabel,
population: group.population || '',
religion: religionLabel,
religionCode: religionCode,
affinityBlock: affinityLabel,
latitude: group.latitude || null,
longitude: group.longitude || null,
description: group.location_description || '',
// Prayer & engagement data (THE KEY FIELDS!)
peoplePraying: Number(group.people_praying || 0),
peopleCommitted: Number(group.people_committed || 0),
adoptedByChurches: Number(group.adopted_by_churches || 0),
// Image fields
imageUrl: imageUrl,
hasPhoto: hasPhoto,
photoCredit: group.picture_credit || null,
imageStatus: hasPhoto ? 'pending' : 'placeholder',
// Keep all original API data
...group
};
});
// Log prayer & engagement statistics
const withPrayer = this.peopleGroups.filter(g => g.peoplePraying > 0).length;
const withCommitted = this.peopleGroups.filter(g => g.peopleCommitted > 0).length;
const withAdoption = this.peopleGroups.filter(g => g.adoptedByChurches > 0).length;
const withImages = this.peopleGroups.filter(g => g.hasPhoto).length;
console.log(`📊 Prayer.Tools API stats:`);
console.log(` Total groups: ${this.peopleGroups.length}`);
console.log(` 🙏 With prayer: ${withPrayer}`);
console.log(` 👥 With committed workers: ${withCommitted}`);
console.log(` ⛪ Adopted by churches: ${withAdoption}`);
console.log(` 📸 With photos: ${withImages}`);
// Start background image testing
this.testImagesInBackground();
},
async fetchFromAPI() {
const source = this.availableSources.find(s => s.id === 'DOXA_API');
const apiUrl = source.url;
console.log('API URL:', apiUrl);
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Guard against HTML responses (bot protection, error pages)
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('text/html')) {
throw new Error('Server returned HTML instead of JSON — the API endpoint may be blocked or unavailable');
}
const data = await response.json();
// Placeholder image for groups without photos
const placeholderUrl = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
// The API returns { "posts": [...] }
const posts = data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []);
if (posts.length === 0) {
console.error('Unexpected API response format:', data);
this.peopleGroups = [];
return;
}
this.peopleGroups = posts.map(group => {
// Determine image URL from API data
const hasPhoto = group.imb_has_photo === '1' || group.imb_has_photo === 1 || group.imb_has_photo === true;
const apiImageUrl = group.imb_picture_url || null;
const imageUrl = (hasPhoto && apiImageUrl) ? apiImageUrl : placeholderUrl;
return {
// Standard fields mapped from API
ID: group.dt_id || group.ID || `api-${Math.random().toString(36).slice(2)}`,
post_title: group.imb_display_name || group.post_title || 'Unknown',
name: group.imb_display_name || group.post_title || 'Unknown',
slug: group.slug || '',
status: group.status || '',
country: group.imb_isoalpha3 || '',
region: group.doxa_wagf_region || '',
wagfBlock: group.doxa_wagf_block || '',
population: group.imb_population || '',
religion: group.imb_reg_of_religion || '',
religionCode: group.imb_reg_of_religion_3 || '',
latitude: group.imb_lat || null,
longitude: group.imb_lng || null,
description: group.imb_people_description || '',
// Image fields
imageUrl: imageUrl,
hasPhoto: hasPhoto,
photoCredit: group.imb_picture_credit_html || '',
imageStatus: hasPhoto && apiImageUrl ? 'pending' : 'placeholder',
// Keep all original API data
...group
};
});
// Log image statistics
const withImages = this.peopleGroups.filter(g => g.hasPhoto && g.imb_picture_url).length;
const withPlaceholder = this.peopleGroups.length - withImages;
console.log(`📸 Image stats: ${withImages} with photos, ${withPlaceholder} using placeholder`);
// Start background image testing for groups that have URLs
this.testImagesInBackground();
},
async fetchFromCSV() {
const source = this.availableSources.find(s => s.id === 'CSV');
const csvUrl = source.url;
console.log('CSV URL:', csvUrl);
const response = await fetch(csvUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const csvText = await response.text();
const parsedData = this.parseCSV(csvText);
// Map all groups to standard format
const allGroups = parsedData.map(group => ({
ID: group.PEID || group.PeopleID3 || `csv-${Math.random()}`,
post_title: group.Name || group.PplNm,
name: group.Name || group.PplNm,
status: group.Status || 'Active',
country: group.Ctry,
region: group.Regn,
population: group.Pop,
language: group.Lang,
religion: group.Rlgn,
peopleID3: group.PeopleID3 && group.PeopleID3.trim() !== '' ? group.PeopleID3.trim() : null,
imageUrl: group.PeopleID3 && group.PeopleID3.trim() !== '' ? this.generateImageUrl(group.PeopleID3.trim()) : null,
imageStatus: 'pending', // 'pending', 'loading', 'loaded', 'failed', 'error'
// Include all original CSV data
...group
}));
// Count cluster statistics
const clusterStats = {};
allGroups.forEach(group => {
if (group.peopleID3) {
if (!clusterStats[group.peopleID3]) {
clusterStats[group.peopleID3] = { total: 0 };
}
clusterStats[group.peopleID3].total++;
}
});
// Initial sort by cluster size
this.peopleGroups = allGroups.sort((a, b) => {
const aHasID = a.peopleID3 ? 1 : 0;
const bHasID = b.peopleID3 ? 1 : 0;
// Groups with PeopleID3 come before groups without
if (aHasID !== bHasID) {
return bHasID - aHasID;
}
// Both have PeopleID3: sort by cluster size
if (aHasID && bHasID) {
const aCluster = clusterStats[a.peopleID3];
const bCluster = clusterStats[b.peopleID3];
if (aCluster.total !== bCluster.total) {
return bCluster.total - aCluster.total;
}
return a.peopleID3.localeCompare(b.peopleID3);
}
return 0;
});
const withPeopleID3 = this.peopleGroups.filter(g => g.peopleID3).length;
const withoutPeopleID3 = this.peopleGroups.length - withPeopleID3;
const uniqueClusters = Object.keys(clusterStats).length;
console.log(`✅ Parsed ${parsedData.length} total rows`);
console.log(` ${withPeopleID3} have PeopleID3, ${withoutPeopleID3} without`);
console.log(` ${uniqueClusters} unique PeopleID3 clusters`);
console.log('Top 5 largest clusters:',
Object.entries(clusterStats)
.sort((a, b) => b[1].total - a[1].total)
.slice(0, 5)
.map(([id, stats]) => `PeopleID3 ${id}: ${stats.total} groups`));
// Start background image testing (non-blocking)
this.testImagesInBackground();
},
async testImagesInBackground() {
console.log('🔍 Starting background image verification...');
// Get groups that need image testing (have a real imageUrl, not placeholder)
const groupsToTest = this.peopleGroups.filter(g =>
g.imageUrl && g.imageStatus === 'pending'
);
// Deduplicate by imageUrl
const uniqueTests = new Map();
groupsToTest.forEach(group => {
if (!uniqueTests.has(group.imageUrl)) {
uniqueTests.set(group.imageUrl, {
url: group.imageUrl,
groupIds: [group.ID],
status: 'pending'
});
} else {
uniqueTests.get(group.imageUrl).groupIds.push(group.ID);
}
});
const totalToTest = uniqueTests.size;
let tested = 0;
let loaded = 0;
let failed = 0;
let errors = 0;
console.log(` Testing ${totalToTest} unique images...`);
// Test images SLOWLY - only 3 at a time with delays
const batchSize = 3; // Small batch size to avoid rate limiting
const delayBetweenBatches = 1000; // 1 second delay between batches
const maxRetries = 3; // Retry failed images up to 3 times
const entries = Array.from(uniqueTests.entries());
const placeholderUrl = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
for (let i = 0; i < entries.length; i += batchSize) {
const batch = entries.slice(i, i + batchSize);
const promises = batch.map(([imageUrl, info]) =>
this.testImageWithRetry(info.url, null, maxRetries).then(result => ({
imageUrl,
groupIds: info.groupIds,
...result
}))
);
const results = await Promise.all(promises);
// Update groups with results
results.forEach(result => {
const { groupIds, status, attempts } = result;
this.peopleGroups.forEach(group => {
if (groupIds.includes(group.ID)) {
group.imageStatus = status;
group.imageAttempts = attempts;
if (status === 'failed' || status === 'error') {
// Fall back to placeholder
group.imageUrl = placeholderUrl;
group.imageStatus = 'placeholder';
}
}
});
tested++;
if (status === 'loaded') {
loaded++;
} else if (status === 'failed') {
failed++;
console.warn(` ❌ 404: ${result.imageUrl} (tried ${attempts} times)`);
} else if (status === 'error') {
errors++;
console.error(` ⚠️ Error: ${result.imageUrl} (tried ${attempts} times)`);
}
});
// Log progress every 10 batches
if ((i / batchSize) % 10 === 0) {
console.log(` Progress: ${tested}/${totalToTest} tested (${loaded} ✅, ${failed} ❌, ${errors} ⚠️)`);
}
// Delay between batches to avoid rate limiting
if (i + batchSize < entries.length) {
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
}
}
console.log(`🎉 Image testing complete!`);
console.log(` ✅ ${loaded} images loaded successfully`);
console.log(` ❌ ${failed} images not found (404) after retries`);
console.log(` ⚠️ ${errors} network/timeout errors after retries`);
console.log(` Note: Errors may be due to rate limiting or slow network`);
},
async testImageWithRetry(url, peopleID3, maxRetries = 3) {
let lastResult = null;
// First, try the primary URL (media.joshuaproject.net with /public/)
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const result = await this.testImageLoadDetailed(url);
// If loaded successfully, return immediately
if (result.status === 'loaded') {
return { ...result, attempts: attempt, urlFormat: 'primary' };
}
lastResult = result;
if (attempt < maxRetries) {
// Wait before retrying (exponential backoff)
const delay = 500 * attempt; // 500ms, 1000ms, 1500ms...
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// Primary URL failed, try fallback URL format
const fallbackUrl = `https://joshuaproject.net/assets/media/profiles/photos/p${peopleID3}.jpg`;
console.log(` 🔄 Trying fallback URL for PeopleID3 ${peopleID3}...`);
const fallbackResult = await this.testImageLoadDetailed(fallbackUrl);
if (fallbackResult.status === 'loaded') {
// Update the group's imageUrl to use the fallback
this.peopleGroups.forEach(group => {
if (group.peopleID3 === peopleID3) {
group.imageUrl = fallbackUrl;
}
});
return { ...fallbackResult, attempts: maxRetries + 1, urlFormat: 'fallback' };
}
// Both primary and fallback failed
return { ...lastResult, attempts: maxRetries + 1, urlFormat: 'none' };
},
testImageLoadDetailed(url) {
return new Promise((resolve) => {
const img = new Image();
const timeout = setTimeout(() => {
img.src = ''; // Cancel loading
resolve({ status: 'error', statusCode: 'timeout', url });
}, 10000); // 10 second timeout (longer for slow networks)
img.onload = () => {
clearTimeout(timeout);
resolve({ status: 'loaded', statusCode: 200, url });
};
img.onerror = () => {
clearTimeout(timeout);
// Assume failed (could be 404 or network error)
resolve({ status: 'failed', statusCode: 404, url });
};
img.src = url;
});
},
parseCSV(csvText) {
// Split by newline but handle quoted fields with embedded newlines
const lines = [];
let currentLine = '';
let inQuotes = false;
for (let i = 0; i < csvText.length; i++) {
const char = csvText[i];
if (char === '"') {
inQuotes = !inQuotes;
currentLine += char;
} else if (char === '\n' && !inQuotes) {
if (currentLine.trim()) {
lines.push(currentLine);
}
currentLine = '';
} else {
currentLine += char;
}
}
// Add last line if exists
if (currentLine.trim()) {
lines.push(currentLine);
}
if (lines.length === 0) return [];
// Get headers from first line - parse CSV properly
const headers = this.parseCSVLine(lines[0]);
console.log('CSV Headers found:', headers.length, 'columns');
console.log('Key columns - PEID:', headers.indexOf('PEID'), 'Name:', headers.indexOf('Name'), 'PeopleID3:', headers.indexOf('PeopleID3'));
const data = [];
// Parse each line
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const values = this.parseCSVLine(line);
const row = {};
headers.forEach((header, index) => {
if (values[index] !== undefined) {
row[header] = values[index];
}
});
data.push(row);
}
return data;
},
parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
// Add last field
result.push(current.trim());
return result;
},
generateImageUrl(peopleID3) {
if (!peopleID3 || peopleID3.trim() === '') {
return null;
}
// Primary URL format (works better): https://media.joshuaproject.net/public/assets/media/profiles/photos/p19007.jpg
// Fallback URL format: https://joshuaproject.net/assets/media/profiles/photos/p18194.jpg
return `https://media.joshuaproject.net/public/assets/media/profiles/photos/p${peopleID3}.jpg`;
},
setDataSource(sourceId) {
if (this.dataSource !== sourceId) {
this.dataSource = sourceId;
// Save preference to localStorage
localStorage.setItem(LS('dataSource'), sourceId);
// Refetch data from new source
this.fetchPeopleGroups();
}
},
addPrayer(groupId) {
if (!this.prayers[groupId]) {
this.prayers[groupId] = 0;
}
this.prayers[groupId]++;
// Save to localStorage for persistence
localStorage.setItem(LS('prayers'), JSON.stringify(this.prayers));
},
addComment(groupId, comment) {
if (!this.comments[groupId]) {
this.comments[groupId] = [];
}
this.comments[groupId].push({
id: Date.now(),
text: comment,
timestamp: new Date().toISOString()
});
// Save to localStorage for persistence
localStorage.setItem(LS('comments'), JSON.stringify(this.comments));
},
loadFromLocalStorage() {
try {
const savedPrayers = localStorage.getItem(LS('prayers'));
const savedComments = localStorage.getItem(LS('comments'));
const savedSource = localStorage.getItem(LS('dataSource'));
if (savedPrayers) {
this.prayers = JSON.parse(savedPrayers);
}
if (savedComments) {
this.comments = JSON.parse(savedComments);
}
if (savedSource && ['PRAY_API'].includes(savedSource)) {
this.dataSource = savedSource;
}
} catch (error) {
console.error('Error loading from localStorage:', error);
}
}
}
})(pinia);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
{
"type": "monolithic",
"label": "Wall of People Groups",
"note": "Monolithic composite (capability 3): ONE root .vue nests the zoomable d3 wall, its cards, and the prayer and comments dialogs. index.js registers exactly ONE tag (wall-of-people-groups); none of the inner components is independently embeddable."
}

View file

@ -0,0 +1,89 @@
<script>
/**
* wall-of-people-groups.vue — Wall of People Groups: every Doxa people group on one zoomable wall.
*
* @description Every Doxa people group as a card on one zoomable prayer wall
* @profile wall-of-people-groups
* @bundle wall-of-people-groups
* @element wall-of-people-groups
*
* MOVED 2026-09-25 from the wall-of-people-groups no-build SPA (far-fast-tools, read-only). Its
* components were plain objects with template strings compiled in the browser; they are now SFCs
* in src/components. This file is the app container from its main.js, without the router (the
* only other route was an API developer panel, which does not ship).
*
* Data: the live pray.doxa.life people-group list (2,107 groups, with photos and prayer counts).
* Prayers and comments stay in this browser's localStorage, under a 'wall-pg:' prefix.
* d3 and Font Awesome are CDN globals, loaded on first mount.
*/
import PeopleGroupsWall from './src/components/PeopleGroupsWall.vue'
import CommentsDialog from './src/components/CommentsDialog.vue'
import PrayerDialog from './src/components/PrayerDialog.vue'
import SourceSelector from './src/components/SourceSelector.vue'
const D3_JS = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'
const FA_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'
let d3Promise = null
function ensureD3() {
if (typeof window === 'undefined' || window.d3) return Promise.resolve()
if (!d3Promise) d3Promise = new Promise((res, rej) => {
const s = document.createElement('script'); s.src = D3_JS
s.onload = () => res(); s.onerror = () => { d3Promise = null; rej(new Error('d3 failed to load')) }
document.head.appendChild(s)
})
return d3Promise
}
// Font Awesome's @font-face only registers from the DOCUMENT; its classes must also be inside the
// shadow root. So the stylesheet goes in both places.
function ensureFontAwesome(root) {
if (typeof document === 'undefined') return
if (!document.head.querySelector(`link[href="${FA_CSS}"]`)) {
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = FA_CSS; document.head.appendChild(l)
}
if (root && root !== document && !root.querySelector(`link[href="${FA_CSS}"]`)) {
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = FA_CSS; root.prepend(l)
}
}
export default {
name: 'WallOfPeopleGroups',
components: { PeopleGroupsWall, CommentsDialog, PrayerDialog, SourceSelector },
data() {
return { ready: false, error: null, commentsDialogVisible: false, prayerDialogVisible: false, selectedGroup: null }
},
mounted() {
ensureFontAwesome(this.$el?.getRootNode?.())
ensureD3().then(() => { this.ready = true }, (e) => { this.error = e.message })
},
methods: {
openCommentsDialog(group) { this.selectedGroup = group; this.commentsDialogVisible = true },
closeCommentsDialog() {
this.commentsDialogVisible = false
setTimeout(() => { this.selectedGroup = null }, 300)
},
openPrayerDialog(group) { this.selectedGroup = group; this.prayerDialogVisible = true },
closePrayerDialog() {
this.prayerDialogVisible = false
setTimeout(() => { if (!this.commentsDialogVisible) this.selectedGroup = null }, 300)
},
handlePrayerConfirmed() { /* PrayerDialog already updated the store */ },
},
}
</script>
<template>
<div class="app-container">
<source-selector />
<people-groups-wall v-if="ready" @open-comments="openCommentsDialog" @open-prayer="openPrayerDialog" />
<div v-else-if="error" class="wall-error">The wall could not load its drawing library: {{ error }}</div>
<comments-dialog :visible="commentsDialogVisible" :group="selectedGroup" @close="closeCommentsDialog" />
<prayer-dialog :is-open="prayerDialogVisible" :group="selectedGroup" @close="closePrayerDialog" @prayer-confirmed="handlePrayerConfirmed" />
</div>
</template>
<style src="./src/wall.css"></style>
<style>
:host { display: block; position: relative; min-height: 600px; }
.app-container { position: relative; width: 100%; height: 100%; }
.wall-error { padding: 24px; color: #3b463d; font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
</style>

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 22,
"profileCount": 34,
"bundleCount": 23,
"profileCount": 35,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life",
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
@ -493,7 +493,7 @@
"bundle": "doxa-vision-card",
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
"output": "doxa-maps/doxa-vision-card/doxa-vision-card.js",
"integrity": "sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9",
"integrity": "sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj",
"elements": [
"doxa-vision-card"
],
@ -509,7 +509,7 @@
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
"embed": {
"iframe": "<iframe src=\"./doxa-vision-card/doxa-vision-card/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>"
},
"instances": [
{
@ -863,7 +863,7 @@
"bundle": "prayer-walk-planner",
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js",
"integrity": "sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY",
"integrity": "sha384-v0hrmAp17LGeHHfWbgdEkrTILIqrZRQh2QDFT1tgxN359vVW/Xt7t+GAQS16uA4U",
"elements": [
"prayer-walk-planner"
],
@ -879,7 +879,7 @@
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"embed": {
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-v0hrmAp17LGeHHfWbgdEkrTILIqrZRQh2QDFT1tgxN359vVW/Xt7t+GAQS16uA4U\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
}
}
]
@ -1059,7 +1059,7 @@
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"output": "doxa-maps/upg-neighbor/upg-neighbor.js",
"integrity": "sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN",
"integrity": "sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi",
"elements": [
"upg-neighbor"
],
@ -1075,7 +1075,7 @@
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"embed": {
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
},
"instances": [
{
@ -1152,6 +1152,32 @@
"bundle": "upg-neighbor"
}
]
},
{
"bundle": "wall-of-people-groups",
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
"output": "doxa-maps/wall-of-people-groups/wall-of-people-groups.js",
"integrity": "sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR",
"elements": [
"wall-of-people-groups"
],
"profiles": [
{
"profile": "wall-of-people-groups",
"file": "wall-of-people-groups.vue",
"description": "Every Doxa people group as a card on one zoomable prayer wall",
"element": "wall-of-people-groups",
"type": "monolithic",
"isMap": false,
"iframeUrl": "./wall-of-people-groups/wall-of-people-groups/index.html",
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
"embed": {
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
},
"bundle": "wall-of-people-groups"
}
]
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-vision-card> custom element. -->
<script src="../../doxa-vision-card.js" integrity="sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9" crossorigin="anonymous"></script>
<script src="../../doxa-vision-card.js" integrity="sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-vision-card> custom element. -->
<script src="../../doxa-vision-card.js" integrity="sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9" crossorigin="anonymous"></script>
<script src="../../doxa-vision-card.js" integrity="sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-vision-card> custom element. -->
<script src="../doxa-vision-card.js" integrity="sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9" crossorigin="anonymous"></script>
<script src="../doxa-vision-card.js" integrity="sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 22,
"profileCount": 34,
"bundleCount": 23,
"profileCount": 35,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life",
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
@ -493,7 +493,7 @@
"bundle": "doxa-vision-card",
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
"output": "doxa-maps/doxa-vision-card/doxa-vision-card.js",
"integrity": "sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9",
"integrity": "sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj",
"elements": [
"doxa-vision-card"
],
@ -509,7 +509,7 @@
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
"embed": {
"iframe": "<iframe src=\"./doxa-vision-card/doxa-vision-card/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>"
},
"instances": [
{
@ -863,7 +863,7 @@
"bundle": "prayer-walk-planner",
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js",
"integrity": "sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY",
"integrity": "sha384-v0hrmAp17LGeHHfWbgdEkrTILIqrZRQh2QDFT1tgxN359vVW/Xt7t+GAQS16uA4U",
"elements": [
"prayer-walk-planner"
],
@ -879,7 +879,7 @@
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"embed": {
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-v0hrmAp17LGeHHfWbgdEkrTILIqrZRQh2QDFT1tgxN359vVW/Xt7t+GAQS16uA4U\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
}
}
]
@ -1059,7 +1059,7 @@
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"output": "doxa-maps/upg-neighbor/upg-neighbor.js",
"integrity": "sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN",
"integrity": "sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi",
"elements": [
"upg-neighbor"
],
@ -1075,7 +1075,7 @@
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"embed": {
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
},
"instances": [
{
@ -1152,6 +1152,32 @@
"bundle": "upg-neighbor"
}
]
},
{
"bundle": "wall-of-people-groups",
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
"output": "doxa-maps/wall-of-people-groups/wall-of-people-groups.js",
"integrity": "sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR",
"elements": [
"wall-of-people-groups"
],
"profiles": [
{
"profile": "wall-of-people-groups",
"file": "wall-of-people-groups.vue",
"description": "Every Doxa people group as a card on one zoomable prayer wall",
"element": "wall-of-people-groups",
"type": "monolithic",
"isMap": false,
"iframeUrl": "./wall-of-people-groups/wall-of-people-groups/index.html",
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
"embed": {
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
},
"bundle": "wall-of-people-groups"
}
]
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <prayer-walk-planner> custom element. -->
<script src="../prayer-walk-planner.js" integrity="sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY" crossorigin="anonymous"></script>
<script src="../prayer-walk-planner.js" integrity="sha384-v0hrmAp17LGeHHfWbgdEkrTILIqrZRQh2QDFT1tgxN359vVW/Xt7t+GAQS16uA4U" 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 <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi" 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 <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi" 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 <upg-neighbor> custom element. -->
<script src="../upg-neighbor.js" integrity="sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN" crossorigin="anonymous"></script>
<script src="../upg-neighbor.js" integrity="sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi" 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 <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi" 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 <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi" 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 <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi" 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

@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>wall-of-people-groups · wall-of-people-groups</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, wall-of-people-groups { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }
</style>
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <wall-of-people-groups> custom element. -->
<script src="../wall-of-people-groups.js" integrity="sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
// 2. ?tk= URL param — token carried in the shared link
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
// *endpoint* is resolved and fetched:
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
// means "read that global"; anything else is used as a URL — so both a global
// name and a plain URL are safe (no SyntaxError footgun).
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"profile":"wall-of-people-groups","dataSource":"pray-tools","instanceId":"wall-of-people-groups-wall-of-people-groups","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
var NESTS_MAPS = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
function askToken() {
if (!status) return;
status.className = 'err';
status.innerHTML = 'map needs a Mapbox token — ' +
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
var go = document.getElementById('tkGo');
if (go) go.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (!v) return;
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
mount(v);
});
}
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
mounted element does not cover shows it through, because every layer above body is
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
unpainted canvas as first supposed. Clearing it here (the one place we already know the
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
function done() {
if (status && status.parentNode) { status.parentNode.removeChild(status); }
document.documentElement.style.background = '#f3f3f1';
document.body.style.background = '#f3f3f1';
}
function mount(token) {
if (!token && !TOKEN_FREE) { return askToken(); }
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("wall-of-people-groups");
el.setAttribute('profile-config', JSON.stringify(cfg));
slot.innerHTML = '';
slot.appendChild(el);
done();
} catch (e) { fail(e && e.message ? e.message : String(e)); }
}
function qp(name) {
try { return new URLSearchParams(location.search).get(name) || ''; }
catch (e) { return ''; }
}
function extractToken(data) {
if (typeof data === 'string') return data.trim();
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
return '';
}
// Rungs 1-3b: an actual token → mount directly, no fetch.
// 2b = localStorage: the paste-once store shared with the staging pages
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
var stored = '';
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
var urlTk = qp('tk');
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
if (TOKEN_FREE) {
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
mount('');
// A token-free composite with no token anywhere: its content renders, but its nested maps
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
if (NESTS_MAPS && !directToken) {
var bar = document.createElement('div');
bar.id = 'status';
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
document.body.appendChild(bar);
status = bar;
askToken();
var goBtn = document.getElementById('tkGo');
if (goBtn) goBtn.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (v) { location.reload(); }
});
}
return;
}
if (directToken) { return mount(directToken); }
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
// A single hard-coded pick used to kill the page when its one url 404'd or was
// blocked by CORS; a shared link must work wherever the tree is hosted.
var candidates = [];
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
addCandidate(window.MAP_TOKEN_URL);
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
addCandidate(DEFAULT_TOKEN_URL);
function fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
askToken();
if (status) {
var d = document.createElement('div');
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
status.insertBefore(d, status.firstChild);
}
return;
}
var url = candidates[i];
return fetch(url)
.then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) {
var tk = extractToken(data);
if (!tk) throw new Error(url + ' → no token in response');
mount(tk);
})
.catch(function (e) {
errors.push(e && e.message ? e.message : String(e));
return fetchToken(i + 1, errors);
});
}
fetchToken(0, []);
})();
</script>
</body>
</html>