Merge remote-tracking branch 'origin/main' into exp-shepherd/shelf-gate

# Conflicts:
#	package.json
#	public/doxa-maps/doxa-maps-spa/manifest.json
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
#	public/doxa-maps/prayer-walk-planner/prayer-walk-planner.js
#	public/doxa-maps/prayer-walk-planner/prayer-walk-planner/index.html
This commit is contained in:
doxa-maps-88655c70 2026-09-25 11:36:00 +00:00
commit ff36d0dfcc
37 changed files with 471 additions and 41 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

@ -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,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

@ -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

@ -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

@ -125,6 +125,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",
@ -202,6 +203,7 @@
"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:poster-wall-localised": "node internal/check-poster-wall-localised.mjs",
"check:experimental-shelf": "node internal/check-experimental-shelf.mjs"
},
"//scripts": {
@ -306,6 +308,7 @@
"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: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": {

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
@ -615,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;' +
@ -688,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

@ -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-pWPAEBiCv2hPheJQmT2/BNGjlg2HpRsSD8vmqX8s6OoizwlLz2Ygp85el90F5w4z",
"integrity": "sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV",
"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-pWPAEBiCv2hPheJQmT2/BNGjlg2HpRsSD8vmqX8s6OoizwlLz2Ygp85el90F5w4z\" 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-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV\" 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-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi",
"integrity": "sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN",
"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-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>"
"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>"
},
"instances": [
{

File diff suppressed because one or more lines are too long

View file

@ -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-pWPAEBiCv2hPheJQmT2/BNGjlg2HpRsSD8vmqX8s6OoizwlLz2Ygp85el90F5w4z",
"integrity": "sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV",
"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-pWPAEBiCv2hPheJQmT2/BNGjlg2HpRsSD8vmqX8s6OoizwlLz2Ygp85el90F5w4z\" 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-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV\" 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-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi",
"integrity": "sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN",
"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-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>"
"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>"
},
"instances": [
{

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-pWPAEBiCv2hPheJQmT2/BNGjlg2HpRsSD8vmqX8s6OoizwlLz2Ygp85el90F5w4z" crossorigin="anonymous"></script>
<script src="../prayer-walk-planner.js" integrity="sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: