Merge main into bundler-1/work (regenerate the tree, re-hash)
Some checks are pending
build-gate / build (push) Waiting to run

The one conflict was in public/doxa-maps/index.html, which generate-tree
writes. Built files are regenerated, not hand-merged: country-posters
was rebuilt because main changed its source (a new two-tab MFE),
regions-dashboard because this branch did, then the tree and the hashes.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 04:54:42 +00:00
commit c1ab013f9e
11 changed files with 723 additions and 48 deletions

View file

@ -0,0 +1,91 @@
// check-mfe-two-tab — a two-tab MFE must actually MOUNT BOTH PANES and switch.
//
// The failure this catches is BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT: every layer
// inside these composites is position:absolute/height:100%, so the whole chain
// resolves to ZERO unless :host is sized. The header still renders and the tab
// strip still paints — the element looks alive and every pane is 0px tall. That is
// the "elements are not embedding" report, and a DOM-presence check passes it.
// So this asserts MEASURED pane height, not mere existence.
//
// It also asserts ONE share affordance (two on one surface shipped once as
// BUG-UPG100-DOUBLE-SHARE) and that clicking tab 2 actually changes the pressed
// tab — a strip that paints but does not switch is the other silent half.
//
// Usage: node internal/check-mfe-two-tab.mjs <url> [expectedTabCount]
import { chromium } from 'playwright'
const URL = process.argv[2]
if (!URL) { console.error('usage: check-mfe-two-tab.mjs <url> [tabs]'); process.exit(2) }
const WANT_TABS = Number(process.argv[3] || 2)
const b = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const pg = await b.newPage({ viewport: { width: 1280, height: 900 } })
const errs = []
pg.on('pageerror', (e) => errs.push(String(e).slice(0, 140)))
await pg.goto(URL, { waitUntil: 'load', timeout: 45000 })
await pg.waitForTimeout(9000)
// Everything lives in a shadow root, so reach through it explicitly.
const r = await pg.evaluate(() => {
// Find the host by SHADOW ROOT, not by rendered content: an element that
// collapsed to 0px still HAS its shadow root, and reporting "not found" would
// misdiagnose the zero-height collapse as a missing element. Measured — removing
// :host produced exactly that misleading message.
const host = [...document.querySelectorAll('*')].find((e) => e.shadowRoot
&& (e.shadowRoot.querySelector('.viewsel, .pane-col, [role="tab"]')
|| /mfe/i.test(e.tagName)))
if (!host) return { error: 'no custom element with a shadow root found on this page' }
const sr = host.shadowRoot
// ⚠ Select the TABS, not their container. '.viewsel .seg' matches the tablist
// WRAPPER as well as its buttons, which counted 3 tabs for a 2-tab strip and
// concatenated their text ("Country PostersMap"). role=tab is the semantic
// anchor ViewSelect already sets, so it cannot drift with the class names.
const tabs = [...sr.querySelectorAll('[role="tab"]')]
.map((t) => (t.textContent || '').trim()).filter(Boolean)
const panes = [...sr.querySelectorAll('.pane')].map((p) => {
const box = p.getBoundingClientRect()
const mounted = !!p.querySelector('.pane-mount > *')
return { h: Math.round(box.height), w: Math.round(box.width), mounted }
})
const shares = sr.querySelectorAll('[aria-label*="hare"], .share-btn, .sharebtn').length
const hostBox = host.getBoundingClientRect()
return { tabs, panes, shares, hostH: Math.round(hostBox.height) }
})
await b.close()
if (r.error) { console.error(`MFE-TWO-TAB: FAIL — ${r.error}`); process.exit(1) }
const fails = []
if (r.tabs.length !== WANT_TABS) fails.push(`${r.tabs.length} tab(s) in the strip, expected ${WANT_TABS}`)
// ★ THE HOST HEIGHT IS THE LOAD-BEARING ASSERTION, not the pane heights.
// MEASURED: squashing the host to 0px leaves its panes still reporting 843px,
// because they are position:absolute and escape the collapsed box. So a
// pane-height check alone does NOT catch BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT —
// the panes look fine while the element occupies no space and the reader sees
// nothing. Only the host measurement reveals it.
if (r.hostH < 100) {
fails.push(`the host element measured ${r.hostH}px tall — :host is not sized, so the `
+ 'element occupies no space even though its panes still measure normally')
}
if (r.panes.length !== WANT_TABS) fails.push(`${r.panes.length} pane(s) mounted, expected ${WANT_TABS}`)
for (const [i, p] of r.panes.entries()) {
if (!p.mounted) fails.push(`pane ${i} has no child element mounted`)
// Kept as a secondary signal only — see the note on hostH above for why this
// one cannot be trusted to catch the collapse on its own.
if (p.h < 100) fails.push(`pane ${i} measured ${p.h}px tall`)
}
if (r.shares > 1) fails.push(`${r.shares} share affordances on one surface — exactly one belongs here`)
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
console.log(` host height : ${r.hostH}px`)
console.log(` tabs : ${r.tabs.join(' | ') || '(none)'}`)
console.log(` panes : ${r.panes.map((p) => `${p.w}x${p.h}${p.mounted ? '' : ' UNMOUNTED'}`).join(', ')}`)
console.log(` share buttons : ${r.shares}`)
console.log(` page errors : ${errs.length}`)
if (fails.length) {
console.error(`\nMFE-TWO-TAB: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log(`\nMFE-TWO-TAB: PASS — ${WANT_TABS} tabs, both panes mounted and sized, one share affordance.`)

View file

@ -0,0 +1,99 @@
// check-provider-switch — ?provider=maplibre must actually SWAP THE ENGINE.
//
// The bug this gates (BUG-UPG100-IGNORES-THE-PROVIDER-SWITCH): my-upg-100-list
// builds its map RAW with `new gl.Map` instead of going through useMapInstance, so
// the page-level switch — which resolves inside that composable — could not reach
// it. MEASURED at the time: five sibling leaves swapped cleanly while this one
// stayed on Mapbox and made 19 requests to api.mapbox.com.
//
// ★ IT COUNTS NETWORK REQUESTS BY HOST, not globals. `window.mapboxgl` can be
// present because a page preloads it, and a map can still be DRAWN by maplibre —
// or the reverse. What the switch is for is not loading a proprietary engine and
// not calling its tile servers, and only the request log shows that.
//
// Usage: node internal/check-provider-switch.mjs <leafUrl>
import { chromium } from 'playwright'
// ⚠ NOT named `URL` — that shadows the global URL constructor, so every
// `new URL(request.url())` below throws and the host tally stays EMPTY while the
// gate happily reports 0 mapbox requests and PASSES. Measured: it did exactly
// that, hiding 4 real requests to events.mapbox.com.
const LEAF = process.argv[2]
if (!LEAF) { console.error('usage: check-provider-switch.mjs <leafUrl>'); process.exit(2) }
async function run(provider) {
const b = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const pg = await b.newPage({ viewport: { width: 1280, height: 900 } })
const hosts = new Map()
let unparsed = 0
pg.on('request', (r) => {
try {
const h = new URL(r.url()).hostname
if (!h) { unparsed += 1; return }
hosts.set(h, (hosts.get(h) || 0) + 1)
} catch { unparsed += 1 }
})
const errs = []
pg.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
const url = provider ? `${LEAF}${LEAF.includes('?') ? '&' : '?'}provider=${provider}` : LEAF
await pg.goto(url, { waitUntil: 'load', timeout: 45000 })
await pg.waitForTimeout(12000)
const globals = await pg.evaluate(() => ({
mapbox: typeof window.mapboxgl !== 'undefined',
maplibre: typeof window.maplibregl !== 'undefined',
}))
await b.close()
const count = (needle) => [...hosts.entries()]
.filter(([h]) => h.includes(needle)).reduce((a, [, n]) => a + n, 0)
// Report what could NOT be parsed rather than swallowing it: a silently empty
// tally is how this gate first passed while measuring nothing at all.
return { mapboxReq: count('mapbox.com'), globals, errs, unparsed, total: [...hosts.values()].reduce((a, n) => a + n, 0) }
}
const def = await run(null)
const ml = await run('maplibre')
// DERIVED from the leaf's own HTML, not a hardcoded number: count the distinct
// api.mapbox.com references the generated page carries, then allow a little slack
// because a stylesheet can be fetched more than once (the upg-100 leaf resolves 4
// distinct urls but issues 5 requests — one css is re-fetched). Hardcoding 5 would
// pass a leaf that legitimately preloads 4 and leaks 1 runtime call.
const pageHtml = await (await fetch(LEAF)).text()
const declared = new Set((pageHtml.match(/https:\/\/api\.mapbox\.com[^"')]+/g) || []))
const PRELOAD_ALLOWANCE = Number(process.env.PROVIDER_PRELOADS ?? (declared.size + 1))
console.log(` page preloads : ${declared.size} distinct api.mapbox.com url(s) in the leaf HTML`)
console.log(` default : ${def.mapboxReq} mapbox / ${def.total} total request(s) `
+ `[mapboxgl=${def.globals.mapbox} maplibregl=${def.globals.maplibre}]`)
console.log(` ?provider=maplibre: ${ml.mapboxReq} mapbox / ${ml.total} total request(s) `
+ `[mapboxgl=${ml.globals.mapbox} maplibregl=${ml.globals.maplibre}]`)
const fails = []
if (!ml.globals.maplibre) {
fails.push('?provider=maplibre did not load the maplibre engine at all')
}
// The RESIDUAL SET is the leaf page's own <link>/<script> preloads — mapbox-gl and
// the geocoder, referenced in the generated index.html header. Those fire before any
// Vue code runs, so no runtime switch can suppress them; removing them is a separate
// concern (the page template), not this bug. What the switch must achieve is that NO
// RUNTIME request goes to Mapbox: measured 19 -> 5 on the upg-100 leaf, and all 5 are
// in the HTML. Asserting "fewer than default" would pass a half-fix at 18.
if (ml.mapboxReq > PRELOAD_ALLOWANCE) {
fails.push(`?provider=maplibre made ${ml.mapboxReq} mapbox request(s), more than the `
+ `${PRELOAD_ALLOWANCE} page preloads — ${ml.mapboxReq - PRELOAD_ALLOWANCE} are RUNTIME `
+ 'calls the switch failed to redirect')
}
if (def.mapboxReq <= PRELOAD_ALLOWANCE) {
fails.push(`the default run made only ${def.mapboxReq} mapbox request(s) — the map is probably `
+ 'not rendering, so this comparison proves nothing')
}
if (!def.total) fails.push('no requests were recorded at all — the tally is broken, not the page')
if (ml.errs.length) fails.push(`${ml.errs.length} page error(s) under maplibre: ${ml.errs[0]}`)
if (fails.length) {
console.error(`\nPROVIDER-SWITCH: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log(`\nPROVIDER-SWITCH: PASS — maplibre loads and mapbox traffic drops `
+ `${def.mapboxReq} -> ${ml.mapboxReq}.`)

View file

@ -57,6 +57,8 @@
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
"check:card-deck": "node internal/check-card-deck.mjs",
"check:poster-raster": "node internal/check-poster-raster.mjs",
"check:provider-switch": "node internal/check-provider-switch.mjs",
"check:mfe-two-tab": "node internal/check-mfe-two-tab.mjs",
"check:share-policy": "node internal/check-share-policy.mjs",
"check:zone-census": "node internal/check-zone-census.mjs",
"check:print-geometry": "node internal/check-print-geometry.mjs",

View file

@ -0,0 +1,12 @@
{
"type": "nested-elements-mfe",
"label": "Country Posters — Wall + Map",
"composes": [
"country-posters/country-poster-wall",
"country-posters/countries-map"
],
"note": "The Driver's 2-tab Country Posters element, structured like my-upg-100-list/upg-100-mfe: a SELECT VIEW switcher (the SHARED library/components/ViewSelect.vue — this is its THIRD consumer, which is why it was extracted rather than copied a third time) over Country Posters at [0,0] and Map at [1,0]. LIST FIRST, MAP SECOND per the Driver's principle that maps are a secondary view. The wall already dispatches 'doxa-country-posters:show-on-map' (country-poster-wall.vue:78); until now only the app shell listened, so the action did nothing in a dropped embed — this shell owns it and swaps to the map tab. pinHint:false: the cloned panes carry no coach-mark, and the composite must declare it because a pane's config comes from here, not from the child's own meta. One ShareButton in the header, sharing the COMPOSITE leaf, per the recorded share policy.",
"config": {
"pinHint": false
}
}

View file

@ -0,0 +1,200 @@
<script setup>
/**
* country-posters-mfe.vue — Country Posters as a TWO-TAB element.
*
* Driver: "Country Posters is supposed to have two tabs, structured like the
* UPG-100 list."
*
* ★ THE CONTROL IS SHARED, NOT COPIED. That is the whole point of the card: this
* is the THIRD place the SELECT VIEW switcher is needed, and it uses the library's
* ViewSelect.vue — the same component upg-100-mfe and regions-mfe render. A third
* hand-rolled copy is exactly what library/components/ViewSelect.vue was extracted
* to prevent (its own header records that a fourth divergent copy is not what the
* library is for).
*
* SAME SHAPE AS upg-100-mfe, deliberately — index.js already says this bundle is
* "the same shape as my-upg-100-list", and copying its structure is what makes the
* two elements behave identically for a reader who moves between them:
* • the pane-wrapper layout (buildPaneColumn + PANE_GRID_STYLES)
* • each pane keeps its own ProfileLoader/Pinia
* • one ShareButton in the header, per the recorded share policy
* (check:share-policy) — the MAP pane must not grow a second one
*
* LIST FIRST, MAP SECOND. The Driver's principle, already applied in upg-100-mfe:
* "maps don't have to be primary. You can use lists and grids for the primary view
* and maps as a secondary view… then they transition into the map." The poster wall
* is the concrete artefact a newcomer can act on; the map is where it is placed.
*/
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue'
import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import ViewSelect from '@map/components/ViewSelect.vue'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
const cfg = inject('profileConfig', ref(null))
const uiStore = inject('uiStore', null)
const isDark = computed(() => !!uiStore?.isDark)
const TAG = 'country-posters-map'
// One instanceId shared by BOTH panes, so a scoped cross-pane event addresses THIS
// embed even when two Country Posters elements sit on the same page.
const instanceId = computed(() => cfg.value?.instanceId || 'country-posters-mfe')
// The map pane needs the Mapbox token the host page was given; the wall does not.
const tk = computed(() => cfg.value?.tk || '')
const spec = computed(() => ({
id: 'country-posters-mfe',
tag: TAG,
// pinHint:false reaches the PANES from here, not from their own meta. A child's
// meta configures only its own LEAF page; nested in this composite each pane is
// handed the config built below, and PinHint.vue reads pinHint off the INJECTED
// profileConfig — so without this the coach-mark reappears over the cloned map.
config: { profile: 'country-poster-wall', instanceId: instanceId.value, pinHint: false },
// ORDER LIVES IN at[0], NOT IN THIS ARRAY'S ORDER. buildPaneColumn SORTS by at[0]
// while ViewSelect renders this array VERBATIM, so the two must agree or the tab
// strip desynchronises from the scroll column.
panes: [
{ id: 'wall', label: 'Country Posters', at: [0, 0], config: { profile: 'country-poster-wall' } },
{ id: 'map', label: 'Map', at: [1, 0], config: { profile: 'countries-map' } },
],
// The switcher lives in this element's own header, so the layout's "… below ↓"
// pill would be a second, redundant control (same call upg-100-mfe makes).
paneHint: false,
}))
const stage = ref(null)
// DERIVED from the pane order, never a hardcoded id — the same trap upg-100-mfe
// records: `ref('map')` silently became wrong the moment the panes were reordered.
const firstPaneId = computed(() => {
const col = spec.value.panes
.filter((p) => (p.at?.[1] ?? 0) === 0)
.sort((a, b) => (a.at?.[0] ?? 0) - (b.at?.[0] ?? 0))
return col[0]?.id || 'wall'
})
const viewRaw = ref(null)
const view = computed({
get: () => viewRaw.value || firstPaneId.value,
set: (v) => { viewRaw.value = v },
})
let column = null
let offScroll = null
// PAGE-LEVEL config keys a pane INHERITS from the host page. A cap-4 composite
// nests children as custom elements in THIS document, so anything the generator
// bakes once per page (where assets are served from, which backend, the theme) has
// to be forwarded explicitly or the child falls back to its own default — the
// BUG-UPG100MFE-ASSET-PATH-DEPTH-404S failure, where every preview 404'd because
// assetsBase never reached the consumer.
// Forwarded by KEY LIST, never by spreading the whole host config, so a pane's own
// `profile`/`instanceId` cannot be overwritten (pane.config is spread last).
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'isDark', 'qa']
function paneElement(pane) {
const el = document.createElement(TAG)
const host = cfg.value || {}
const inherited = {}
for (const k of INHERITED_PAGE_KEYS) {
if (host[k] !== undefined && host[k] !== null && host[k] !== '') inherited[k] = host[k]
}
// `lang` is the alias the page may carry; children read `locale`.
if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang
const pcfg = { ...inherited, ...spec.value.config, ...pane.config }
if (tk.value) pcfg.tk = tk.value
el.setAttribute('profile-config', JSON.stringify(pcfg))
return el
}
function go(id) {
view.value = id
column?.scrollTo(id)
}
// ── tab 1 -> tab 2 (shell seam) ─────────────────────────────────────────────
// country-poster-wall already dispatches this scoped window event when a card's
// "Show on map" is clicked — country-poster-wall.vue:78 sets event-name. Until now
// ONLY the app shell listened (app-wrapper-material/index.html), so the action did
// nothing in a dropped embed or a nested composite: the event fired into a document
// with no handler. Owning it here is what makes the two tabs one element rather
// than two pages that happen to share a bundle.
const SHOW_ON_MAP = 'doxa-country-posters:show-on-map'
function onShowOnMap(e) {
const d = e?.detail || {}
if (d.instanceId && d.instanceId !== instanceId.value) return
go('map')
}
onMounted(() => {
if (!stage.value) return
column = buildPaneColumn(spec.value, { host: stage.value })
if (!column) return
for (const pane of column.panes) {
if (!pane.config) continue
column.mountEl(pane.id)?.appendChild(paneElement(pane))
}
// Tabs are a readout as well as a control — follow the user's scroll.
offScroll = column.onScroll(() => {
const cur = column.current()
if (cur && cur !== view.value) view.value = cur
})
window.addEventListener(SHOW_ON_MAP, onShowOnMap)
})
onBeforeUnmount(() => {
window.removeEventListener(SHOW_ON_MAP, onShowOnMap)
offScroll?.()
column?.destroy()
column = null
})
// Share the WHOLE MFE: this composite profile's own leaf page, TREE-RELATIVE
// (<bundle>/<profile>/index.html) — ShareButton joins it to the served tree root.
// ★ Sharing the COMPOSITE, not a child, is the BUG-SHARE-FROM-CAP4-COMPOSITE bug
// this pattern already paid for once.
const SHARE_EMBED_PATH = 'country-posters/country-posters-mfe/index.html'
// Child components' <style> blocks are dropped by defineCustomElement, so ALL CSS
// goes through useShadowStyles: the layout's grid styles + this element's chrome.
useShadowStyles(PANE_GRID_STYLES + `
/* SELF-SIZE (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT). Every layer inside this element is
position:absolute / height:100%, so the whole chain resolves to ZERO unless the HOST is
sized. A standalone leaf page papers over it with a page-level rule; inside a tab layout
no such rule exists and every pane collapses to 0 — the "elements are not embedding"
report. Declaring :host makes the element fill its slot in every deploy method. */
:host{ display:block; width:100%; height:100%; min-height:420px; }
/* ── palette: DX-life material light. NO AUTO-DARK (Driver 2026-09-20): a system set to
dark mode must not flip this element. ── */
.cpmfe{
--ui-bg:#ffffff; --ui-bg-elevated:#f3f3f1; --ui-bg-accented:#f3f3f1;
--ui-border:#d1d4cd; --ui-text:#3b463d; --ui-text-muted:#6d766e; --ui-primary:#92b195;
--header:56px;
position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;
background:var(--ui-bg); color:var(--ui-text);
font:14px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;
}
.cpmfe .hdr{
flex:0 0 var(--header); height:var(--header); position:relative;
display:flex; align-items:center; justify-content:flex-end; padding:0 12px;
background:var(--ui-bg); border-bottom:1px solid var(--ui-border);
}
/* Only the PLACEMENT lives here — the control's metrics are the shared component's. */
.viewsel{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); }
.cpmfe .stage{ position:relative; flex:1 1 auto; min-height:0; }
.pane > .pane-mount > *{ position:absolute; inset:0; width:100%; height:100%; display:block; }
`, 'country-posters-mfe')
</script>
<template>
<div class="cpmfe">
<header class="hdr">
<ViewSelect
:views="spec.panes"
:current="view"
legend="Select View"
label="Views"
@select="go"
/>
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
</header>
<div class="stage" ref="stage"></div>
</div>
</template>

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-uGk+BENGktebtR4QYc630qqtM8kDPK5uxPo6mwoWMqD/CTrala4WfAA3hNyg9ZuB" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7" 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 <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-uGk+BENGktebtR4QYc630qqtM8kDPK5uxPo6mwoWMqD/CTrala4WfAA3hNyg9ZuB" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>country-posters-mfe · country-posters</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* SCROLL-PAGE leaf (cap-4 nested-elements-mfe composite: a StackLayout scroll-stack). The DOCUMENT
owns the scroll — the element takes its content's NATURAL height and grows the page, so a reader
scrolls PAST one stacked section down to the next (BUG-DASHBOARD-CANT-SCROLL-DOWN). This is the
OPPOSITE of the fixed-viewport map/single-element leaf below: NO position:fixed, NO inset:0,
height:auto so the page can grow. See StackLayout.vue's "THE LAYOUT OWNS THE SCROLL" contract. */
html { height: 100%; }
html, body { margin: 0; background: #0a0e15; }
body { min-height: 100%; overflow-y: auto; }
/* MIN-HEIGHT IN VIEWPORT UNITS, NOT PERCENT (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT).
min-height:100% here resolved against BODY's computed height, which is auto (body only has
min-height:100%, never a definite height) — and a percentage of auto collapses to 0. Any
element whose own chrome is position:absolute / height:100% (upg-100-mfe -> .pane-col -> .pane)
then measured 0 and the leaf looked empty. 100dvh depends on NO ancestor, keeps the page
growable (it is a MIN), and is correct on mobile with dynamic toolbars. */
#slot, country-posters-map { position: static; inset: auto; width: 100%; min-height: 100dvh; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }
</style>
</head>
<body>
<div id="slot"></div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
// 2. ?tk= URL param — token carried in the shared link
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
// *endpoint* is resolved and fetched:
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
// means "read that global"; anything else is used as a URL — so both a global
// name and a plain URL are safe (no SyntaxError footgun).
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"pinHint":false,"profile":"country-posters-mfe","dataSource":"pray-tools","instanceId":"country-posters-country-posters-mfe","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
function askToken() {
if (!status) return;
status.className = 'err';
status.innerHTML = 'map needs a Mapbox token — ' +
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
var go = document.getElementById('tkGo');
if (go) go.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (!v) return;
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
mount(v);
});
}
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
mounted element does not cover shows it through, because every layer above body is
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
unpainted canvas as first supposed. Clearing it here (the one place we already know the
element is up) is a one-shot style write — no resize, no repaint loop. The Driver is
PHOTOSENSITIVE and a 60fps resize-strobe was already fixed this session; do NOT turn this into
a per-frame or per-event handler. */
function done() {
if (status && status.parentNode) { status.parentNode.removeChild(status); }
document.documentElement.style.background = '#f3f3f1';
document.body.style.background = '#f3f3f1';
}
function mount(token) {
if (!token && !TOKEN_FREE) { return askToken(); }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("country-posters-map");
el.setAttribute('profile-config', JSON.stringify(cfg));
slot.innerHTML = '';
slot.appendChild(el);
done();
} catch (e) { fail(e && e.message ? e.message : String(e)); }
}
function qp(name) {
try { return new URLSearchParams(location.search).get(name) || ''; }
catch (e) { return ''; }
}
function extractToken(data) {
if (typeof data === 'string') return data.trim();
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
return '';
}
// Rungs 1-3b: an actual token → mount directly, no fetch.
// 2b = localStorage: the paste-once store shared with the staging pages
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
var stored = '';
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
var urlTk = qp('tk');
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
if (TOKEN_FREE) { return mount(''); }
if (directToken) { return mount(directToken); }
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
// A single hard-coded pick used to kill the page when its one url 404'd or was
// blocked by CORS; a shared link must work wherever the tree is hosted.
var candidates = [];
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
addCandidate(window.MAP_TOKEN_URL);
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
addCandidate(DEFAULT_TOKEN_URL);
function fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
askToken();
if (status) {
var d = document.createElement('div');
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
status.insertBefore(d, status.firstChild);
}
return;
}
var url = candidates[i];
return fetch(url)
.then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) {
var tk = extractToken(data);
if (!tk) throw new Error(url + ' → no token in response');
mount(tk);
})
.catch(function (e) {
errors.push(e && e.message ? e.message : String(e));
return fetchToken(i + 1, errors);
});
}
fetchToken(0, []);
})();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -3,7 +3,7 @@
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 22,
"profileCount": 33,
"profileCount": 34,
"bundles": [
{
"bundle": "all-people-groups",
@ -154,10 +154,14 @@
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
"output": "doxa-maps/country-posters/country-posters.js",
"integrity": "sha384-uGk+BENGktebtR4QYc630qqtM8kDPK5uxPo6mwoWMqD/CTrala4WfAA3hNyg9ZuB",
"integrity": "sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7",
"elements": [
"country-posters-map"
],
"composes": [
"country-posters/country-poster-wall",
"country-posters/countries-map"
],
"profiles": [
{
"profile": "countries-map",
@ -170,7 +174,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/countries-map/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=\"./country-posters/country-posters.js\" integrity=\"sha384-uGk+BENGktebtR4QYc630qqtM8kDPK5uxPo6mwoWMqD/CTrala4WfAA3hNyg9ZuB\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -184,7 +188,21 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-poster-wall/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=\"./country-posters/country-posters.js\" integrity=\"sha384-uGk+BENGktebtR4QYc630qqtM8kDPK5uxPo6mwoWMqD/CTrala4WfAA3hNyg9ZuB\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
"profile": "country-posters-mfe",
"file": "country-posters-mfe.vue",
"description": "",
"element": "country-posters-map",
"type": "nested-elements-mfe",
"isMap": true,
"iframeUrl": "./country-posters/country-posters-mfe/index.html",
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-posters-mfe/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=\"./country-posters/country-posters.js\" integrity=\"sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
}
]