Research Gallery: embed on someone else's page, in both modes

The gallery is going onto the DX-life research page, and nothing had tried
it on a page it didn't generate. From a second origin, a bare
<script> + <doxa-maps-spa> drew over the host's header, and 8 of 13 tabs
showed an error card.

Three causes, each fixed in the shell:

- No :host box. The layout root is position:absolute, so it anchored to the
  viewport instead of the element. :host is now a positioned block with a
  default height the host can override.
- A foreign page sets none of the globals a leaf page bakes. Every data call
  hit the host's own origin and got HTML back. The manifest now publishes a
  `runtime` block (apiBaseUrl + the public token every leaf already carries).
  The shell fills window.MAP_APP_API_URL / window.MAP_TOKEN only when the page
  hasn't set them, and forwards tk in profile-config.
- Nested elements resolved assets to <tree>/../assets, a sibling pool the
  publisher doesn't ship (404 live). The shell now forwards an absolute
  <tree>/assets. This also fixes the iframe mode, where Cities with Internet
  and Gospel Resources were failing the same way.

Also adds a `tab` attribute (plus ?tab=) to deep-link into one map, and a
`tab-change` event for the host.

check:gallery-embed drives every tab in both modes from a second origin. It
passes here and fails on the previous tree (7 findings).

EMBED.md documents the contract. The Traefik template now sends CORS for the
whole tree: live manifest.json answers without Access-Control-Allow-Origin.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 19:03:37 +00:00
parent e7f84bb84d
commit 3a2cf0e8f2
12 changed files with 391 additions and 49 deletions

View file

@ -63,7 +63,17 @@ The built tree has TWO kinds of page, and they need DIFFERENT framing policy:
| Surface | Paths | `frame-ancestors` |
|---|---|---|
| **Direct embed** (the product) | `<bundle>/<profile>/index.html`, `<bundle>/<profile>/<instance>/index.html` — the bare map pages the manifest's `iframeUrl` points at | **`*`** — framable on any partner site (that's the whole point) |
| **Staging** (internal preview) | tree root `index.html`, per-bundle `<bundle>/index.html`, the SPA shell | **`'self'`** — preview only |
| **Staging** (internal preview) | tree root `index.html`, per-bundle `<bundle>/index.html` | **`'self'`** — preview only |
The Research Gallery (`doxa-maps-spa/doxa-maps-spa/index.html`) is a direct-embed page like any
other leaf. Its full embed contract (script and iframe) is in
`plugin-designs/doxa-maps-spa/EMBED.md`.
**CORS is a separate requirement, and it covers the whole tree.** A script embed runs in the
partner's document, so everything a composed surface `fetch()`es — `manifest.json` at the tree
root, and `assets/` — needs `Access-Control-Allow-Origin: *`. Applying it only to the ≥2-segment
embed pages leaves `manifest.json` uncovered. `bun run check:gallery-embed` proves the whole
chain from a second origin.
`frame-ancestors` can ONLY be set by an HTTP response header (HTML `<meta>` cannot set it), so
**the server/CDN owns this split — the bundler cannot enforce it.** Whoever serves the tree must

View file

@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* check-research-gallery-embed.mjs — the cap-5 Research Gallery (<doxa-maps-spa>) must embed on
* SOMEONE ELSE'S page, in both embed modes, with every nested map painting.
*
* WHAT THIS PROTECTS. The gallery is going onto the DX-life research page. Every other gate loads
* the gallery on its OWN generated leaf page, which bakes the three things a nested map needs —
* window.MAP_APP_API_URL, window.MAP_TOKEN and a correct assets path — and pins the tag
* position:fixed. A foreign page supplies none of that, so those gates cannot see the embed failing.
* Measured 2026-09-24 before the fix, from a second origin: the gallery drew OVER the host's header
* (the element had no :host box, so the layout's position:absolute root anchored to the viewport),
* and 8 of 13 tabs showed an error card because every data call hit the host's origin.
*
* HOW. Serves the built tree with internal/serve-strict.mjs (production parity: strict directories,
* ACAO:*) on 127.0.0.1, and a stand-in host page on `localhost` — a DIFFERENT ORIGIN, so CORS is
* genuinely exercised. The host page has its own header and footer around the embed. Then, per mode:
* js — `<script src=…/doxa-maps-spa.js>` + a bare `<doxa-maps-spa>` tag, NO attributes: the
* zero-config embed the docs promise.
* iframe — an <iframe> at the gallery's leaf page.
* and for every tab in the rail: select it, wait for it to mount, then assert.
*
* ASSERTIONS (a FAIL names the tab and the reason):
* 1. js mode: the layout root sits INSIDE the element's box (not at the viewport origin).
* 2. every tab reaches state 'ready' (never 'error', never stuck 'idle'/'loading').
* 3. every CURATED tab paints a WebGL canvas — they are all maps or map composites.
* Experimental tabs need only mount: some are cards or legacy frames with no canvas.
* 4. zero failed requests to the TREE's own origin (a 404/CORS on our assets is our bug; third-
* party tile/glyph hiccups are reported, not failed — they are not this repo's to fix).
*
* PRECONDITION: a built tree in public/doxa-maps (this starts its own servers; no dev server).
* Usage: node internal/check-research-gallery-embed.mjs [--mode js|iframe] [--only id,id]
* Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no browser)
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync } 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 argOf = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const MODES = argOf('--mode', '') ? [argOf('--mode', '')] : ['js', 'iframe']
const ONLY = argOf('--only', '') ? argOf('--only', '').split(',') : null
// ★ FREE PORTS, ASKED OF THE OS — never fixed ones. A stray server left on a fixed port by an
// earlier crashed run answered in place of the tree under test, and this gate PASSED a known-broken
// tree (caught by its own negative control, 2026-09-24): spawn failed with EADDRINUSE silently and
// the old server served the fixed build. A port the OS just handed out cannot be someone else's.
const freePort = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const TREE_PORT = await freePort()
const HOST_PORT = await freePort()
const TREE = `http://127.0.0.1:${TREE_PORT}/doxa-maps`
if (!existsSync(resolve(ROOT, 'public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js'))) {
console.log('SKIP check-research-gallery-embed: no built gallery at public/doxa-maps/doxa-maps-spa/')
process.exit(2)
}
let chromium
try { ({ chromium } = await import('playwright')) } catch {
console.log('SKIP check-research-gallery-embed: playwright not installed'); process.exit(2)
}
let mode = 'js'
const hostPage = () => {
const embed = mode === 'iframe'
? `<iframe src="${TREE}/doxa-maps-spa/doxa-maps-spa/index.html" style="width:100%;height:760px;border:0"></iframe>`
: `<script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa></doxa-maps-spa>`
return `<!doctype html><html><head><meta charset=utf-8><title>host</title>
<style>body{margin:0} header,footer{height:60px;background:#eee}</style></head>
<body><header id=hdr>HOST HEADER</header><main style="max-width:1200px;margin:0 auto">${embed}</main><footer>HOST FOOTER</footer></body></html>`
}
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT)], { cwd: ROOT, stdio: 'ignore' })
const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(hostPage()) }).listen(HOST_PORT, 'localhost')
const cleanup = () => { try { tree.kill() } catch {} try { host.close() } catch {} }
await new Promise(r => setTimeout(r, 800))
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
const fails = []
try {
for (mode of MODES) {
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
const own = new Set(), foreign = new Set()
const note = (url, why) => (url.startsWith(`http://127.0.0.1:${TREE_PORT}/`) ? own : foreign).add(why + ' ' + url.split('?')[0])
page.on('response', r => { if (r.status() >= 400) note(r.url(), String(r.status())) })
page.on('requestfailed', q => { const e = (q.failure() && q.failure().errorText) || ''; if (!/ABORTED/.test(e)) note(q.url(), e) })
await page.goto(`http://localhost:${HOST_PORT}/`)
const frame = mode === 'iframe' ? await (await page.waitForSelector('iframe')).contentFrame() : page.mainFrame()
await frame.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 30000 })
if (mode === 'js') {
const g = await page.evaluate(() => {
const s = document.querySelector('doxa-maps-spa'), v = s.shadowRoot.querySelector('.vtl')
const a = s.getBoundingClientRect(), b = v.getBoundingClientRect()
return { host: [a.top, a.left, a.width, a.height].map(Math.round), vtl: [b.top, b.left, b.width, b.height].map(Math.round) }
})
const inside = g.vtl[0] >= g.host[0] - 1 && g.vtl[1] >= g.host[1] - 1 && g.vtl[2] <= g.host[2] + 1 && g.vtl[3] <= g.host[3] + 1 && g.host[3] >= 400
console.log(`${inside ? 'PASS' : 'FAIL'} [js] layout inside the host box — host ${g.host} layout ${g.vtl}`)
if (!inside) fails.push(`[js] layout escaped the <doxa-maps-spa> box (host ${g.host}, layout ${g.vtl})`)
}
const tabs = await frame.evaluate(() => document.querySelector('doxa-maps-spa')._instance.proxy.tabs.map(t => ({ id: t.id, discovered: !!t.discovered })))
for (const { id, discovered } of tabs) {
if (ONLY && !ONLY.includes(id)) continue
await frame.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id)
let v
for (let k = 0; k < 50; k++) {
await page.waitForTimeout(400)
v = await frame.evaluate(id => {
const vm = document.querySelector('doxa-maps-spa')._instance.proxy
const i = vm.tabs.findIndex(x => x.id === id), t = vm.tabs[i]
const el = vm._slots && vm._slots[i] && vm._slots[i].firstElementChild
let canvas = 0
const walk = r => { for (const n of r.querySelectorAll('*')) { if (n.tagName === 'CANVAS') canvas++; if (n.shadowRoot) walk(n.shadowRoot) } }
if (el) { walk(el); if (el.shadowRoot) walk(el.shadowRoot) }
return { state: t.state, error: t.error, canvas }
}, id)
if (v.state === 'error' || (v.state === 'ready' && (v.canvas || discovered && k > 5))) break
}
const needCanvas = !discovered
const ok = v.state === 'ready' && (!needCanvas || v.canvas > 0)
const why = v.state !== 'ready' ? `state ${v.state}${v.error ? ' — ' + v.error : ''}` : (needCanvas && !v.canvas ? 'mounted but painted no map canvas' : '')
console.log(`${ok ? 'PASS' : 'FAIL'} [${mode}] ${id}${why ? ' — ' + why : ''}`)
if (!ok) fails.push(`[${mode}] ${id}: ${why}`)
}
for (const f of own) { console.log(`FAIL [${mode}] tree request failed: ${f}`); fails.push(`[${mode}] ${f}`) }
for (const f of foreign) console.log(`note [${mode}] third-party request failed (not gated): ${f}`)
await page.close()
}
} finally { await browser.close(); cleanup() }
if (fails.length) {
console.log(`\ncheck-research-gallery-embed: FAIL (${fails.length})`); for (const f of fails) console.log(' - ' + f)
process.exit(1)
}
console.log('\ncheck-research-gallery-embed: PASS — the Research Gallery embeds on a foreign origin in ' + MODES.join(' + ') + ' mode')

View file

@ -1233,6 +1233,16 @@ function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) {
embedBase: EMBED_BASE,
bundleCount: bundles.length,
profileCount: bundles.reduce((n, b) => n + b.profiles.length, 0),
// THE PAGE GLOBALS A LEAF PAGE BAKES, published for embeds that have no leaf page. A composed
// surface (the cap-5 Research Gallery) mounted by `<script>` on a FOREIGN page runs in that
// page's document, which carries neither window.MAP_APP_API_URL nor window.MAP_TOKEN — measured
// 2026-09-24: 8 of 13 gallery tabs errored with the host's HTML in place of API JSON. The shell
// reads this block as its default (a host attribute still wins). ADDITIVE: every key is optional
// and old consumers ignore it. The token is the same opt-in public pk. every leaf already bakes.
runtime: {
...(DEST_CONFIG.apiBaseUrl ? { apiBaseUrl: DEST_CONFIG.apiBaseUrl } : {}),
...(bakedToken ? { token: bakedToken } : {}),
},
// `extraConfig` is a GENERATOR-INTERNAL carrier (<profile>.meta.json `config` → the leaf
// page's BASE_CONFIG) and is stripped here so the published manifest shape is unchanged.
// Consumers read the manifest as a contract; a new key would be a silent schema change.

View file

@ -146,6 +146,7 @@
"check:synced-css": "node internal/check-synced-css-copies.mjs",
"check:composite-reg": "node internal/check-composite-registration.mjs",
"check:filter-width": "node internal/check-filter-width-stable.mjs",
"check:gallery-embed": "node internal/check-research-gallery-embed.mjs # the cap-5 Research Gallery must embed on a FOREIGN ORIGIN (a stand-in host page on localhost, the built tree on 127.0.0.1) in BOTH modes — bare <script>+<doxa-maps-spa> and <iframe> — with the layout inside the host box and every curated tab painting a map. Starts its own servers on OS-assigned ports; needs only a built tree. --mode js|iframe, --only <tab,tab>. Exit 2 = SKIP (no built tree / no playwright).",
"check:spa-tab-order": "node internal/check-spa-tab-order.mjs # the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
"check:backlog-status": "node internal/check-backlog-status-enum.mjs",
"check:mjs-parse": "node internal/check-mjs-parses.mjs",

View file

@ -0,0 +1,78 @@
# Embedding the Research Gallery
`<doxa-maps-spa>` (labeled **Research Gallery**) is the capability-5 app shell: one element whose
tab rail nests every production map. It is built to drop onto somebody else's page. Below,
`TREE` means the URL of the built `doxa-maps/` folder wherever it is served, for example
`https://YOUR-HOST/doxa-maps`.
## Script embed (recommended)
```html
<script src="TREE/doxa-maps-spa/doxa-maps-spa.js"></script>
<doxa-maps-spa></doxa-maps-spa>
```
That is the whole embed. The element finds its tree from its own `<script>` URL, reads
`TREE/manifest.json`, and takes the data API and public map token from the manifest's
`runtime` block. It sizes itself as a block that is `85vh` tall (minimum 480px) and lays out
inside its own box. To change the height, either:
```html
<doxa-maps-spa style="height: 900px"></doxa-maps-spa>
<!-- or, from your stylesheet -->
<style> doxa-maps-spa { --doxa-maps-spa-height: 70vh; } </style>
```
## iframe embed
```html
<iframe src="TREE/doxa-maps-spa/doxa-maps-spa/index.html"
style="width:100%; height:85vh; border:0" allow="fullscreen"></iframe>
```
Add `?tab=<id>` to the `src` to open a specific map.
## Attributes (all optional)
| Attribute | Default | What it does |
|---|---|---|
| `tab` | device default (Big Picture on desktop, Simple Research on phones) | Tab id to open first. Changing it later switches tabs. `?tab=` on the page URL works too. |
| `language` | parent page / saved choice | UI language pushed to every tab (`en`, `es`, `fr`, …). |
| `api-base-url` | manifest `runtime.apiBaseUrl` | Data API. Only applied when the page has not already set `window.MAP_APP_API_URL`. |
| `tk` | manifest `runtime.token` | Mapbox public token. Only fills `window.MAP_TOKEN` when the page has not set one. |
| `token-url` | none | Token endpoint forwarded to each map. |
| `tree-base` | derived from the script URL | Pin the tree root explicitly (needed only when the script is re-hosted apart from its tree). |
| `heading` | `DX-Maps` | Shell title (shown only when the shell header is enabled). |
Tab ids: `dashboard-plus-grid` (Big Picture), `research-map-plus-grid` (Simple Research),
`regions-dashboard`, `upg-100`, `prayer-map` (Prayer Progress), `cities-internet`,
`gospel-resources`, and every Experimental bundle by its bundle name.
## Events
The element dispatches `tab-change` whenever the visible map changes, so the host page can
mirror it into its own URL or analytics:
```js
document.querySelector('doxa-maps-spa')
.addEventListener('tab-change', (e) => console.log(e.detail[0].id, e.detail[0].label))
```
## What the server must send
Both headers are **required** on a host other than the one serving the tree:
- **`Access-Control-Allow-Origin: *` on the whole tree**, including `manifest.json` at its root
and `assets/`. The gallery and its maps `fetch()` these after mounting, and `fetch()` enforces
CORS; a `<script>` tag alone does not. If `manifest.json` lacks the header, every tab shows
"Could not load …".
- **`Content-Security-Policy: frame-ancestors *`** on `doxa-maps-spa/doxa-maps-spa/index.html`,
for the iframe embed only.
`serving/traefik-embed-headers.yaml` has a ready rule for both.
## Prove it
```sh
bun run check:gallery-embed # serves the built tree + a foreign host page, drives every tab in both modes
```

View file

@ -727,8 +727,21 @@ export default defineComponent({
// effectiveLocale below, which prefers this prop, then profile-config, then the manual switcher.
// NOTE: no `locale` PROP here on purpose — `locale` is a data() field (the manual switcher's
// state); a same-named prop+data collides in Vue. The alias lives in profile-config instead.
language: { type: String, default: '' }
language: { type: String, default: '' },
// ── THE EMBED CONTRACT (card DELEGATE-NEW-TAB-REGA-RESEARCH-GALLERY-CAP5-EMBEDDABILITY) ──
// A host that drops <doxa-maps-spa> onto its own page (the DX-life research page) supplies NONE
// of the page globals a generated leaf page bakes — window.MAP_APP_API_URL, window.MAP_TOKEN, a
// correct assets path. Measured 2026-09-24 from a second origin: the shell mounted and 8 of 13
// tabs painted an error card, every data call returning the HOST's HTML ("API returned text/html
// … Server at may be down"). Each of these is therefore settable here, and each defaults from
// the tree's own manifest.json (its `runtime` block), so a bare two-line embed needs no config.
// See EMBED.md beside this file.
apiBaseUrl: { type: String, default: '' },
// Which tab opens first, by tab id (e.g. "upg-100", "gospel-resources"). Lets a host page deep-
// link into one map of the gallery. Falls back to ?tab= on the page URL, then the device default.
tab: { type: String, default: '' }
},
emits: ['tab-change'],
setup() {
// index.js provides the tree base it derived from the running <script> URL. Prop/attr override
// it if present (resolved in `base` below).
@ -764,6 +777,10 @@ export default defineComponent({
// scriptUrls are resolved against THIS, not the document — see resolve(). Empty until a
// manifest lands, which is exactly when there are no scriptUrls to resolve anyway.
manifestBase: '',
// The manifest's `runtime` block — the build's own apiBaseUrl + public map token, published so
// an embed on a foreign page can default to them. {} until a manifest lands (or on an older
// tree that predates the block), in which case only host-supplied values are forwarded.
runtime: {},
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
locale: loadLocale(),
localeOpen: false,
@ -800,6 +817,24 @@ export default defineComponent({
if (!raw) return '' // relative: leaf pages sit beside this bundle's folder
return raw.replace(/\/+$/, '') + '/'
},
// The ABSOLUTE url of the tree's shared asset pool, forwarded to every nested element.
//
// ★ WHY THE SHELL MUST SAY IT. The pool ships INSIDE the tree (<tree>/assets/ — generate-tree's
// assetsBaseFor), but a nested element given no absolute assetsBase falls to
// library/utils/embedBase.js resolveAssetsBase, which climbs to <tree>/../assets — the web-root
// SIBLING pool the publisher does not ship. Measured 2026-09-24: live
// /plugin-assets/doxa-maps/assets/tables/… = 200, /plugin-assets/assets/tables/… = 404, and
// locally the sibling copy lacks internet-by-country.json / gospel-by-country.json, so
// Cities with Internet and Gospel Resources failed in BOTH embed modes. Fonts fared worse: a
// raw './assets' resolved against the shell's own leaf page (…/doxa-maps-spa/doxa-maps-spa/assets).
// An absolute value is ladder rung 1 there, so saying it here fixes every tab at once.
// Precedence: an absolute host value, then the directory the manifest answered from (that IS the
// tree root in both the built and dev trees), then nothing (children keep their own ladder).
assetsBase() {
const v = String(this.cfg.assetsBase || '').trim()
if (/^https?:\/\//i.test(v) || v.startsWith('/')) return v.replace(/\/+$/, '')
return this.manifestBase ? this.manifestBase.replace(/\/+$/, '') + '/assets' : ''
},
// The GROUPED rail model the shared VerticalTabsLayout paints, built from NAV_MODEL (the Driver's
// ported sections/folder) projected over the REAL tabs. Each row's `key` = the tab id (stable),
// which onSelect maps back to the tab index the mount machinery uses — so the sections are pure
@ -883,7 +918,7 @@ export default defineComponent({
this.watchWidth()
// Open the first tab immediately (its map is the one load the SPA pays on open). The shared
// VerticalTabsLayout owns rail open/close + narrow-screen collapse now.
this.select(this.firstVisibleIndex())
this.select(this.initialIndex())
// …then read the manifest in the background: it completes each curated tab (tag + scriptUrl) and
// fills the Experimental folder. Deliberately AFTER select() and never awaited — the rail must
// paint instantly. select() defers the actual mount until scriptUrl exists, and applyManifest
@ -911,19 +946,13 @@ export default defineComponent({
try {
const slot = this._slots && this._slots[this.activeIndex]
const el = slot && slot.firstElementChild
if (el && el.setAttribute) {
// Rebuild the SAME config the mount used, so a language switch cannot silently drop the
// profile/instance/token and re-render a different view in the new language.
const pc = { profile: t.profile || t.id, instanceId: 'dms-' + t.id }
if (t.instance) pc.instanceId = t.instance
if (loc) pc.locale = loc
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
if (this.base) pc.treeBase = this.base
el.setAttribute('profile-config', JSON.stringify(pc))
}
// Rebuild the SAME config the mount used (childConfig), so a language switch cannot silently
// drop the profile/instance/token and re-render a different view in the new language.
if (el && el.setAttribute) el.setAttribute('profile-config', JSON.stringify(this.childConfig(t)))
} catch { /* non-fatal: the tab keeps its previous language */ }
}
},
// A host re-pointing the `tab` attribute after mount (e.g. from its own nav) switches the gallery.
tab(id) { if (id) this.onSelect(id) }
},
methods: {
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
@ -1039,6 +1068,10 @@ export default defineComponent({
// then append the discovered Experimental tabs — and finally (re)drive the active tab's mount,
// which could not run before its scriptUrl existed.
applyManifest(m) {
// The build's published defaults for a foreign-page embed (apiBaseUrl + public token). Set the
// page globals BEFORE select() below mounts anything — a nested map reads them at init/fetch.
this.runtime = (m && m.runtime && typeof m.runtime === 'object') ? m.runtime : {}
this.applyPageGlobals()
const patch = resolveTabsFromManifest(m, TAB_DEFS)
const activeId = this.activeKey
// PRUNE rather than paint a dead row: a curated tab whose bundle is absent from this tree can
@ -1060,8 +1093,29 @@ export default defineComponent({
this.tabs = kept
// Re-point activeIndex at the SAME tab id — pruning can shift indices under it, and activeIndex
// is what every pane is keyed on. If that tab is gone, fall back to the first visible row.
// A host-requested tab may be one only the manifest could supply (an Experimental bundle), so
// it is honoured here as well as at mount.
const want = this.requestedTab()
const w = want ? this.tabs.findIndex(t => t.id === want) : -1
const i = this.tabs.findIndex(t => t.id === activeId)
this.select(i >= 0 ? i : this.firstVisibleIndex())
if (w >= 0 && !this.isBigPictureDisabled(want) && !this._honouredTab) this.select(w)
else this.select(i >= 0 ? i : this.firstVisibleIndex())
this._honouredTab = true
},
// The tab a HOST asked to open first: the `tab` attribute, then profile-config.tab, then ?tab= on
// the page URL (so an iframe src or a shared link can deep-link). '' when nobody asked.
requestedTab() {
let q = ''
try { q = new URLSearchParams(location.search).get('tab') || '' } catch { /* non-browser */ }
return String(this.tab || this.cfg.tab || q || '').trim()
},
// The index to open on mount: the requested tab when it exists and is enabled at this width,
// otherwise the device default (firstVisibleIndex).
initialIndex() {
const want = this.requestedTab()
const i = want ? this.tabs.findIndex(t => t.id === want) : -1
if (i >= 0) this._honouredTab = true
return (i >= 0 && !this.isBigPictureDisabled(want)) ? i : this.firstVisibleIndex()
},
// Switch UI language. Persist the choice and let the `effectiveLocale` WATCHER apply it — it
// re-sets profile-config on the active tab's element, generically, with no per-tab special case.
@ -1140,6 +1194,9 @@ export default defineComponent({
}
const t = this.tabs[i]
if (!t) return
// Tell the host which map is showing (a CustomEvent 'tab-change' on <doxa-maps-spa>, detail[0] =
// { id, label }), so a host page can mirror it into its own URL or analytics.
if (prevIndex !== i || !this._announced) { this._announced = true; this.$emit('tab-change', { id: t.id, label: t.label }) }
// Not yet completed by the manifest — applyManifest calls select() again once it lands.
if (!t.scriptUrl) return
// A heavy tab's slot only exists after its v-if subtree renders, so wait a tick before mounting
@ -1166,6 +1223,43 @@ export default defineComponent({
// deleted rather than kept "just in case": an unreachable second token path is one more thing to
// keep in sync, and the ?locale= it emitted is precisely what produced the 404 URLs the Driver
// saw (".../index.html?locale=en").
// THE ONE profile-config every nested element receives — built here and nowhere else, so the mount
// and the language watcher can never disagree about what a tab was told.
childConfig(t) {
// `profile` is the manifest's profile name, NOT the tab id: one tag can serve several profiles
// (gospel-resources ships four views through <gospel-resources-map>), so the tab id would select
// the wrong view — or none. instanceId stays tab-scoped so two panes of the same element never
// collide; a preset instance replaces it only where this tree ships one.
const pc = { profile: t.profile || t.id, instanceId: t.instance || ('dms-' + t.id) }
if (this.effectiveLocale) pc.locale = this.effectiveLocale
// TOKEN LADDER. Direct mounting shares THIS document, so a leaf page's baked token does not
// apply — there is no leaf page. Host prop/config first, then the tree's published public token
// (manifest runtime), and when neither exists the element keeps its own ladder (window.MAP_TOKEN
// on the SPA's own leaf page, which bakes it).
const tk = this.tk || this.cfg.tk || this.runtime.token
if (tk) pc.tk = tk
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
// PASS THE TREE BASE DOWN — a nested cap-5 shell (or any element that fetches the manifest)
// cannot guess it, and guessing is what produced the original 404 class.
if (this.base) pc.treeBase = this.base
if (this.assetsBase) pc.assetsBase = this.assetsBase
return pc
},
// The PAGE GLOBALS a generated leaf page bakes and a foreign host page does not — set here, and
// ONLY when the page has not set them itself: a host's own value wins, always.
// window.MAP_APP_API_URL — the data API every nested map calls. library/api/apiBaseUrl.js reads
// only this global (there is no profile-config path), so the shell cannot pass it any other way.
// window.MAP_TOKEN — profile-config.tk (childConfig) reaches every element with a ProfileLoader,
// but a BAKED composite mounts its map components directly and reads only this global.
// Measured 2026-09-24: Big Picture (dashboard-plus-grid) logged "No Mapbox access token
// provided" on a foreign page and painted no map, while every other tab took tk from config.
applyPageGlobals() {
if (typeof window === 'undefined') return
const api = (this.apiBaseUrl || this.cfg.apiBaseUrl || this.runtime.apiBaseUrl || '').trim()
if (api && !window.MAP_APP_API_URL) window.MAP_APP_API_URL = api.replace(/\/+$/, '')
const tk = this.tk || this.cfg.tk || this.runtime.token
if (tk && !window.MAP_TOKEN) window.MAP_TOKEN = tk
},
// Mount a tab's REAL custom element: inject the bundle's script once, wait for the tag to be
// registered, then create it in the pane's empty slot. This is the ONE path for every tab.
mountElement(i) {
@ -1190,26 +1284,7 @@ export default defineComponent({
// Remove the loading hint, insert the real element with its profile-config.
slot.innerHTML = ''
const el = document.createElement(t.tag)
// `profile` is the manifest's profile name, NOT the tab id: one tag can serve several
// profiles (gospel-resources ships four views through <gospel-resources-map>), so the tab
// id would select the wrong view — or none. instanceId stays tab-scoped so two panes of the
// same element never collide.
const pc = { profile: t.profile || t.id, instanceId: 'dms-' + t.id }
// A preset instance, only where this tree actually ships one (see resolveTabsFromManifest).
if (t.instance) pc.instanceId = t.instance
// Honor the effective language on first mount too (not only on a later manual switch), so a
// tab opened with an inherited `language` prop already renders in that language.
if (this.effectiveLocale) pc.locale = this.effectiveLocale
// TOKEN LADDER. Direct mounting shares THIS document, so a nested map reads the page's
// window.MAP_TOKEN — the leaf pages' baked token no longer applies, because there are no
// leaf pages. Forward whatever the host handed us; each element keeps its own full ladder
// when we pass nothing.
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
// PASS THE TREE BASE DOWN — a nested cap-5 shell (or any element that fetches the manifest)
// cannot guess it, and guessing is what produced the original 404 class.
if (this.base) pc.treeBase = this.base
el.setAttribute('profile-config', JSON.stringify(pc))
el.setAttribute('profile-config', JSON.stringify(this.childConfig(t)))
// ★ ALSO SET tree-base AS A PLAIN ATTRIBUTE, not only inside profile-config. Not every
// element reads profile-config: an element that mounts its OWN SFC has no ProfileLoader
// above it and therefore no provide('profileConfig'), so it takes tree-base as a prop
@ -1247,6 +1322,20 @@ export default defineComponent({
</script>
<style>
/* ★ THE HOST BOX — what makes a bare <doxa-maps-spa> embeddable on someone else's page.
The shared layout's root is `.vtl { position:absolute; inset:0 }`, and a custom element is
display:INLINE with no positioning by default. So on any page that did not happen to wrap the tag
in a positioned box, .vtl anchored to the nearest positioned ANCESTOR — usually the viewport —
and the gallery painted over the host's own header. Measured 2026-09-24 on a foreign test page:
the element's box sat at (59,100) 1200x760 while .vtl drew at (0,0) 1400x760, covering the site
header. The generated leaf page never showed it because it pins the tag `position:fixed`.
:host makes the element its OWN containing block with a real height, so the gallery always lives
inside the box the host placed it in. The height is a DEFAULT, not a lock: a host's own
style="height:…" (or any page rule) outranks :host, and --doxa-maps-spa-height tunes it without
CSS specificity games. */
:host { display: block; position: relative; height: var(--doxa-maps-spa-height, 85vh); min-height: 480px; }
:host([hidden]) { display: none; }
/* The shell (top bar + rail + content frame) is the shared VerticalTabsLayout. Only the PANE-local
styles for this SPA's own tab content remain here — panes fill the shell's content slot, and the
element slot / loading hint each fill their pane.

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../../doxa-maps-spa.js" integrity="sha384-1f+/rBSGo/1jyUmRmGg34CIU/PcGkB7M3jah1DO0ocb5O0zj81wpUemXcz4WAFz8" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-SH+FIgM3C8Ust21eJajB31WomemcBSfbjrUyfYkqFgwdpPi3kGgSbPxebycQGIUU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../doxa-maps-spa.js" integrity="sha384-1f+/rBSGo/1jyUmRmGg34CIU/PcGkB7M3jah1DO0ocb5O0zj81wpUemXcz4WAFz8" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-SH+FIgM3C8Ust21eJajB31WomemcBSfbjrUyfYkqFgwdpPi3kGgSbPxebycQGIUU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,10 @@
"embedBase": ".",
"bundleCount": 22,
"profileCount": 35,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life",
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
},
"bundles": [
{
"bundle": "all-people-groups",
@ -298,7 +302,7 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "doxa-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-1f+/rBSGo/1jyUmRmGg34CIU/PcGkB7M3jah1DO0ocb5O0zj81wpUemXcz4WAFz8",
"integrity": "sha384-SH+FIgM3C8Ust21eJajB31WomemcBSfbjrUyfYkqFgwdpPi3kGgSbPxebycQGIUU",
"elements": [
"doxa-maps-spa"
],
@ -322,7 +326,7 @@
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"embed": {
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/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-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-1f+/rBSGo/1jyUmRmGg34CIU/PcGkB7M3jah1DO0ocb5O0zj81wpUemXcz4WAFz8\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-SH+FIgM3C8Ust21eJajB31WomemcBSfbjrUyfYkqFgwdpPi3kGgSbPxebycQGIUU\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
},
"instances": [
{

View file

@ -3,8 +3,18 @@
# Direct-embed pages (>=2 path segments under the tree) must be framable anywhere;
# staging pages stay preview-only. frame-ancestors is header-only, so the proxy
# owns this — the bundler can only document and template it.
#
# CORS (added 2026-09-24): the WHOLE tree must send Access-Control-Allow-Origin, not only the
# embed pages. A composed surface (the cap-5 Research Gallery, cap-4 composites) mounted by
# <script> on a partner page fetch()es manifest.json at the tree ROOT and assets/ after it mounts,
# and fetch() enforces CORS. Measured live: manifest.json answered 200 with NO ACAO (it sits one
# segment deep, outside the embed-pages rule), so every gallery tab would fail on doxa.life.
http:
middlewares:
doxa-maps-cors:
headers:
accessControlAllowOriginList: ["*"]
accessControlAllowMethods: ["GET", "HEAD", "OPTIONS"]
doxa-maps-embed-framable:
headers:
customResponseHeaders:
@ -14,5 +24,10 @@ http:
doxa-maps-embed-pages:
rule: "PathRegexp(`^/doxa-maps/[^/]+/[^/]+/`)"
priority: 70 # above the site catch-all
middlewares: [doxa-maps-embed-framable]
middlewares: [doxa-maps-embed-framable, doxa-maps-cors]
service: your-static-service
doxa-maps-tree:
rule: "PathPrefix(`/doxa-maps/`)"
priority: 60 # below the embed-pages router, above the site catch-all
middlewares: [doxa-maps-cors]
service: your-static-service