Host locale: gate the profile-config contract, forward ?lang= into staging embeds, stop losing gate stderr
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

- check:host-locale-contract: every leaf loaded with ONLY profile-config.locale="ar" must draw at
  least half the Arabic the same page draws at ?lang=ar; regions-list is the positive control.
  Fails on this commit for 18 leaves (bundles that never provide appI18n, and every composite).
- check:staging-lang: the staging page at ?lang=ar embeds its leaf with lang=ar and the Gospel map
  renders gospel.mode.gap in Arabic. staging-forge.js (iframe + open link) and
  staging-reference-v2.html now forward ?lang= / ?locale= via withLang(); fails on the parent.
- browser-slot.sh: `exec 9>f 2>/dev/null` redirected the whole script's stderr, and the gate
  exec'd after it, to /dev/null — FAIL lines and "not measuring" messages were being discarded.
  The redirection is now scoped to the open.
- verify-map-paint: the two regions map leaves join the default list (both paint, local and live).
- Vision card reads ?lang= / ?locale= like the rest of the library.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 12:17:26 +00:00
parent d78d67f197
commit cfdbea7f9c
15 changed files with 288 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@
"tree": "node internal/generate-tree.mjs", "tree": "node internal/generate-tree.mjs",
"gallery": "node internal/generate-gallery.mjs", "gallery": "node internal/generate-gallery.mjs",
"check:drop": "node internal/drop-portability-check.mjs", "check:drop": "node internal/drop-portability-check.mjs",
"check:host-locale-contract": "node internal/check-host-locale-contract.mjs",
"check:parse": "node internal/inline-parse-check.mjs", "check:parse": "node internal/inline-parse-check.mjs",
"verify": "node library/scripts/research/verify-all.mjs", "verify": "node library/scripts/research/verify-all.mjs",
"verify:full": "node library/scripts/research/verify-all.mjs --full", "verify:full": "node library/scripts/research/verify-all.mjs --full",
@ -168,6 +169,7 @@
"check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs", "check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs",
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison", "check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
"check:click-sheet": "node plugin-designs/gospel-resources/scripts/verify-gospel-click-sheet.mjs", "check:click-sheet": "node plugin-designs/gospel-resources/scripts/verify-gospel-click-sheet.mjs",
"check:staging-lang": "node internal/check-staging-forwards-lang.mjs",
"check:stale-sweep-claim": "node internal/check-no-stale-sweep-claim.mjs # a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `the check:stale-sweep-claim:poison entry`. Static, sub-second.", "check:stale-sweep-claim": "node internal/check-no-stale-sweep-claim.mjs # a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `the check:stale-sweep-claim:poison entry`. Static, sub-second.",
"check:surfaces-read-the-tier": "node internal/check-surfaces-read-the-tier.mjs", "check:surfaces-read-the-tier": "node internal/check-surfaces-read-the-tier.mjs",
"check:surfaces-read-the-tier:poison": "node internal/check-surfaces-read-the-tier.mjs --poison", "check:surfaces-read-the-tier:poison": "node internal/check-surfaces-read-the-tier.mjs --poison",
@ -229,6 +231,7 @@
"check:facet-changes-map:self-test": "reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.", "check:facet-changes-map:self-test": "reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.",
"check:heartbeat": "the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).", "check:heartbeat": "the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
"check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.", "check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
"check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir <tree> for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.",
"check:provider-switch": "the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.", "check:provider-switch": "the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.",
"check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.", "check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.",
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.", "check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
@ -281,6 +284,7 @@
"check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.", "check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
"check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.", "check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
"check:click-sheet": "the COUNTRY-CLICK SHEET without a GPU: every country in the roster resolves to >=1 pin, every link the sheet would render is absolute, and has_audio stays the STRING \"unknown\" where the pool was never checked (collapsing it to false would read as \"no audio exists\"). It had been CRASHING on a path deleted when data moved out of public/, and its two inputs were a private by-country copy and the curated 2,106-row subset the sheet stopped reading — so it was unrunnable AND aimed at files the surface no longer uses. Joins by ISO3 now, not by country-name substring. Carries its own 4-case self-test. Static, sub-second.", "check:click-sheet": "the COUNTRY-CLICK SHEET without a GPU: every country in the roster resolves to >=1 pin, every link the sheet would render is absolute, and has_audio stays the STRING \"unknown\" where the pool was never checked (collapsing it to false would read as \"no audio exists\"). It had been CRASHING on a path deleted when data moved out of public/, and its two inputs were a private by-country copy and the curated 2,106-row subset the sheet stopped reading — so it was unrunnable AND aimed at files the surface no longer uses. Joins by ISO3 now, not by country-name substring. Carries its own 4-case self-test. Static, sub-second.",
"check:staging-lang": "the tree's staging page (public/doxa-maps/index.html, from internal/staging-forge.js) opened at ?lang=ar forwards lang=ar to the leaf it embeds (iframe src AND the open link), and the Gospel map inside renders gospel.mode.gap in Arabic. Negative-tested: the tree built before withLang() FAILS. --dir <tree>. Through browser-slot.sh.",
"check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.", "check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.",
"check:surfaces-read-the-tier": "a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.", "check:surfaces-read-the-tier": "a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.",
"check:link-rot": "re-tests a stratified sample of links we already VERIFIED, because nothing else ever re-tests one: a publisher can retire a page and the map keeps offering it, still labelled 'link checked'. THREE BANDS, each measured rather than assumed, each with its controls in band every run: plain STATUS where a status is evidence (jesusfilm.org among them — it does 404 on the /watch/jesus/<slug> shape the map ships, and the old exclusion was inherited from the retired ?languageId= URL); API-MAPPED for live.bible.is, whose page answers 200 for any fileset while /api/bibles/<id> answers 404; and REDIRECT-LANDING for fivefish.org, where a published code lands on its numbered language page and an unknown one is swept to /regions. If a control misbehaves it reports PROVED NOTHING rather than a pass. Only knowgod.com is left out — byte-identical page for any URL; the verifier proves those through Cru's API. ~3 minutes, network.", "check:link-rot": "re-tests a stratified sample of links we already VERIFIED, because nothing else ever re-tests one: a publisher can retire a page and the map keeps offering it, still labelled 'link checked'. THREE BANDS, each measured rather than assumed, each with its controls in band every run: plain STATUS where a status is evidence (jesusfilm.org among them — it does 404 on the /watch/jesus/<slug> shape the map ships, and the old exclusion was inherited from the retired ?languageId= URL); API-MAPPED for live.bible.is, whose page answers 200 for any fileset while /api/bibles/<id> answers 404; and REDIRECT-LANDING for fivefish.org, where a published code lands on its numbered language page and an unknown one is swept to /regions. If a control misbehaves it reports PROVED NOTHING rather than a pass. Only knowgod.com is left out — byte-identical page for any URL; the verifier proves those through Cru's API. ~3 minutes, network.",

View file

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

View file

@ -493,7 +493,7 @@
"bundle": "doxa-vision-card", "bundle": "doxa-vision-card",
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js", "scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
"output": "doxa-maps/doxa-vision-card/doxa-vision-card.js", "output": "doxa-maps/doxa-vision-card/doxa-vision-card.js",
"integrity": "sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9", "integrity": "sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj",
"elements": [ "elements": [
"doxa-vision-card" "doxa-vision-card"
], ],
@ -509,7 +509,7 @@
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js", "scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./doxa-vision-card/doxa-vision-card/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./doxa-vision-card/doxa-vision-card/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>" "script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>"
}, },
"instances": [ "instances": [
{ {

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

@ -493,7 +493,7 @@
"bundle": "doxa-vision-card", "bundle": "doxa-vision-card",
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js", "scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
"output": "doxa-maps/doxa-vision-card/doxa-vision-card.js", "output": "doxa-maps/doxa-vision-card/doxa-vision-card.js",
"integrity": "sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9", "integrity": "sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj",
"elements": [ "elements": [
"doxa-vision-card" "doxa-vision-card"
], ],
@ -509,7 +509,7 @@
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js", "scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./doxa-vision-card/doxa-vision-card/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./doxa-vision-card/doxa-vision-card/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>" "script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>"
}, },
"instances": [ "instances": [
{ {