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
18
README.md
|
|
@ -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>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.8 MiB |
BIN
documentation/readme-images/exp-prayer-walk-planner.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
documentation/readme-images/exp-wall-of-people-groups.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 425 KiB After Width: | Height: | Size: 425 KiB |
|
Before Width: | Height: | Size: 891 KiB After Width: | Height: | Size: 891 KiB |
|
Before Width: | Height: | Size: 758 KiB After Width: | Height: | Size: 751 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 700 KiB After Width: | Height: | Size: 676 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
|
|
|||
97
internal/check-basemap-labels-localized.mjs
Normal 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.`)
|
||||
168
internal/check-poster-wall-localised.mjs
Normal 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.`)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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', '')
|
||||
|
|
|
|||
|
|
@ -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', '')
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', '')
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
8
public/doxa-maps/doxa-maps-spa/manifest.json
generated
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
2
public/doxa-maps/index.html
generated
8
public/doxa-maps/manifest.json
generated
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||