Regions element gets the Select View tabs, shared with UPG-100
Some checks are pending
build-gate / build (push) Waiting to run

The Regions bundle shipped only tab 1, so the Select View switcher the
UPG-100 list shows had nowhere to live. Adds the composite that carries
it over two panes -- Regions List and Region Dashboards -- plus the
Region Dashboards tab itself, which summarises a region or a country
from the same live endpoint and the same rollup tab 1 uses.

Clicking a region heading or a country row on tab 1 now forwards that
selection and swaps to tab 2 with it applied.

The switcher itself is extracted into library/components/ViewSelect.vue
rather than copied a third time: it had two implementations already, in
the app shell and hand-copied into upg-100-mfe. It takes the views and
the current id as props and knows nothing about maps or panes, so the
placement stays with each header. UPG-100 now renders from the shared
component and is unchanged on screen -- same 45px height, 13px labels,
8px radius.
This commit is contained in:
doxa-maps-88655c70 2026-09-23 19:08:00 +00:00
parent 3e95c6bbee
commit bca3d58c3e
9 changed files with 989 additions and 45 deletions

View file

@ -0,0 +1,92 @@
<!--
ViewSelect.vue — the "SELECT VIEW" switcher: a fieldset + legend wrapping a row of
underline-indicator tabs.
THE CANONICAL SOURCE is the app shell, layouts/app-wrapper-material/index.html
(`.viewsel` / `.seg`, a PrimeVue-style Fieldset). This component is that control
extracted VERBATIM — same structure (semantic <fieldset>/<legend>), same metrics
(8px radius, 10px/700/.7px-tracking uppercase legend, 13px/500 buttons with
6px 12px 7px padding), same 2px accent underline on the pressed tab.
WHY IT LIVES IN THE LIBRARY. It had two independent implementations before this
file existed — the shell's vanilla one and a hand-copied Vue port inside
my-upg-100-list/upg-100-mfe.vue — and a third consumer (regions-dashboard) was
about to copy it again. A control with three consumers and no map-specific
assumptions is what this library is for; a fourth divergent copy is not.
PARAMETERIZED, per library/components/WARNING.md — it takes props, not assumptions:
it knows nothing about maps, panes, bundles or scrolling. It renders the views it
is handed and reports which one was clicked. Positioning is the CALLER's job:
this component ships no `position`/`top`/`left`, so a header that wants it
absolutely centred styles `.viewsel` itself (as both the shell and the MFE do).
Props:
views — [{ id, label }, …] the views to offer, in order
current — id of the active view
legend — the fieldset's legend text (default "Select View")
label — accessible name for the tablist (default "Views")
Emits:
select(id) — a view's tab was clicked
-->
<script setup>
import { useShadowStyles } from '../composables/useShadowStyles.js'
defineProps({
views: { type: Array, default: () => [] },
current: { type: String, default: '' },
legend: { type: String, default: 'Select View' },
label: { type: String, default: 'Views' },
})
defineEmits(['select'])
// Child components' <style> blocks are dropped by defineCustomElement, so the CSS
// goes through useShadowStyles like every other library component.
//
// NO BACKTICKS ANYWHERE IN THIS STRING, including in comments: one stray backtick
// terminates the JS template literal and the build stops compiling. Hence string
// concatenation rather than one template literal (house style, AGENT-BRIEF.md).
useShadowStyles(
// Metrics copied from the shell's .viewsel; colours come from the ui-* custom
// properties the host element already declares, with the material-light palette
// as the fallback so this renders correctly even outside a themed host.
'.viewsel{ margin:0; padding:0 10px 2px;' +
' border:1px solid var(--ui-border,#d1d4cd); border-radius:8px;' +
' background:var(--ui-bg,#ffffff); }' +
// Legend default alignment is inline-start; the shell centres it.
'.viewsel > legend{ margin:0 auto; padding:0 6px; font-size:10px;' +
' text-transform:uppercase; letter-spacing:.7px;' +
' color:var(--ui-text-muted,#6d766e); font-weight:700; }' +
// Slim UNDERLINE indicator, not a filled button.
'.seg{ display:flex; gap:2px; }' +
'.seg button{ position:relative; display:flex; align-items:center; gap:6px;' +
' border:0; background:none; color:var(--ui-text-muted,#6d766e);' +
' padding:6px 12px 7px; font-size:13px; font-weight:500; cursor:pointer;' +
' transition:color .15s; font-family:inherit; }' +
'.seg button::after{ content:""; position:absolute; left:8px; right:8px; bottom:0;' +
' height:2px; border-radius:2px 2px 0 0; background:transparent;' +
' transition:background .15s; }' +
'.seg button:hover{ color:var(--ui-text,#3b463d); }' +
'.seg button:hover::after{ background:var(--ui-border,#d1d4cd); }' +
'.seg button[aria-pressed="true"]{ color:var(--ui-primary,#92b195); font-weight:600; }' +
'.seg button[aria-pressed="true"]::after{ background:var(--ui-primary,#92b195); }',
'view-select'
)
</script>
<template>
<fieldset class="viewsel">
<legend>{{ legend }}</legend>
<div class="seg" role="tablist" :aria-label="label">
<button
v-for="v in views"
:key="v.id"
type="button"
role="tab"
:aria-pressed="current === v.id ? 'true' : 'false'"
:aria-selected="current === v.id ? 'true' : 'false'"
@click="$emit('select', v.id)"
>{{ v.label }}</button>
</div>
</fieldset>
</template>

View file

@ -10,7 +10,9 @@
* 'upg-100-list': it composes the bundle's two sibling elements — the UPG-100
* map (`upg-100-list`) and the poster wall (`poster-wall`) — as PANES using the
* SAME pane-wrapper layout the shell nests (buildPaneColumn + PANE_GRID_STYLES),
* and the SAME View switcher (.viewsel / .seg) the shell shows in its header.
* and the SAME View switcher the shell shows in its header — now the shared
* components/ViewSelect.vue, which is that control extracted from this file so
* regions-dashboard could reuse it instead of copying it a third time.
*
* Copied conventions (see layouts/app-wrapper-material/index.html, 'upg-100-list'):
* • panes: { id:'map', label:'Map', at:[0,0] } and
@ -33,6 +35,7 @@
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))
@ -167,31 +170,15 @@ useShadowStyles(PANE_GRID_STYLES + `
display:flex; align-items:center; justify-content:flex-end; padding:0 12px;
background:var(--ui-bg); border-bottom:1px solid var(--ui-border);
}
/* ── Select View fieldset (copied from the shell; centred in the header) ── */
/* ── Select View fieldset ──
The control's own metrics now live in the SHARED library component
(components/ViewSelect.vue) — this file used to carry a hand-copied second
implementation of the shell's .viewsel/.seg rules. Only the PLACEMENT stays
here, because centring in this particular header is this layout's decision,
not the control's. */
.viewsel{
position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
margin:0; padding:0 10px 2px; border:1px solid var(--ui-border);
border-radius:8px; background:var(--ui-bg);
}
.viewsel > legend{
margin:0 auto; padding:0 6px; font-size:10px; text-transform:uppercase;
letter-spacing:.7px; color:var(--ui-text-muted); font-weight:700;
}
.seg{display:flex;gap:2px}
.seg button{
position:relative; display:flex; align-items:center; gap:6px;
border:0; background:none; color:var(--ui-text-muted);
padding:6px 12px 7px; font-size:13px; font-weight:500; cursor:pointer;
transition:color .15s; font-family:inherit;
}
.seg button::after{
content:''; position:absolute; left:8px; right:8px; bottom:0; height:2px;
border-radius:2px 2px 0 0; background:transparent; transition:background .15s;
}
.seg button:hover{color:var(--ui-text)}
.seg button:hover::after{background:var(--ui-border)}
.seg button[aria-pressed="true"]{color:var(--ui-primary);font-weight:600}
.seg button[aria-pressed="true"]::after{background:var(--ui-primary)}
/* ── stage: the pane-wrapper column mounts here (its .pane-col is absolute) ── */
.upgmfe .stage{ position:relative; flex:1 1 auto; min-height:0; }
/* the live custom element fills its pane */
@ -202,18 +189,13 @@ useShadowStyles(PANE_GRID_STYLES + `
<template>
<div class="upgmfe">
<header class="hdr">
<fieldset class="viewsel">
<legend>Select View</legend>
<div class="seg" role="tablist" aria-label="Views">
<button
v-for="p in spec.panes" :key="p.id"
type="button" role="tab"
:aria-pressed="view === p.id ? 'true' : 'false'"
:aria-selected="view === p.id ? 'true' : 'false'"
@click="go(p.id)"
>{{ p.label }}</button>
</div>
</fieldset>
<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>

View file

@ -22,16 +22,30 @@
<body>
<div class="wrap">
<h1>regions-dashboard · staging</h1>
<p class="sub">Tab 1, "Regions List" — an independently scrollable list of WAGF regions and
their countries. Clicking a region or country emits a selection event
(<code>region-selected</code> / <code>country-selected</code>) rather than navigating.</p>
<p class="sub">The 2-tab Regions element: a SELECT VIEW switcher over "Regions List" and
"Region Dashboards". Clicking a region or country on tab 1 swaps to tab 2 with that
selection applied. Each tab is also mountable on its own, below.</p>
<div class="slot">
<div class="label">profile-config.profile = "regions-list"</div>
<div class="label">profile-config.profile = "regions-mfe" &nbsp;— the composite (both tabs)</div>
<div class="frame">
<regions-dashboard profile-config='{"profile":"regions-mfe"}'></regions-dashboard>
</div>
</div>
<div class="slot" style="margin-top:24px">
<div class="label">profile-config.profile = "regions-list" &nbsp;— tab 1 alone</div>
<div class="frame">
<regions-dashboard profile-config='{"profile":"regions-list"}'></regions-dashboard>
</div>
</div>
<div class="slot" style="margin-top:24px">
<div class="label">profile-config.profile = "region-dashboards" &nbsp;— tab 2 alone</div>
<div class="frame">
<regions-dashboard profile-config='{"profile":"region-dashboards"}'></regions-dashboard>
</div>
</div>
</div>
<script>

View file

@ -3,15 +3,19 @@
*
* Bundle entry — registers the regions-dashboard web component.
*
* This bundle currently ships ONE flat profile owned by this slice — regions-list.vue
* ("Regions List", tab 1 of the Driver's 2-tab Regions Dashboard). A teammate owns tab 2
* ("Region Dashboards") and the shell that hosts both tabs; their profile(s) land as
* additional flat .vue files in this same folder and are picked up automatically by the
* glob below — no change needed here when that lands.
* This bundle ships the Driver's 2-tab Regions element as three flat profiles:
* regions-list.vue — tab 1, "Regions List"
* region-dashboards.vue — tab 2, "Region Dashboards"
* regions-mfe.vue — the COMPOSITE that carries the SELECT VIEW switcher over both
* tabs and forwards tab 1's selection to tab 2
*
* The composite nests the two tabs as <regions-dashboard> custom elements of this SAME bundle,
* each with its own `profile` — so one download serves all three, exactly as my-upg-100-list's
* upg-100-mfe does. That is why the glob below must keep matching every flat .vue here.
*
* Usage from a host page:
* <script src="/doxa-maps/regions-dashboard/regions-dashboard.js"></script>
* <regions-dashboard profile-config='{"profile":"regions-list"}'></regions-dashboard>
* <regions-dashboard profile-config='{"profile":"regions-mfe"}'></regions-dashboard>
*
* `profile` must match a flat .vue filename in this folder (without the .vue).
* The profile-config attribute is parsed by ProfileLoader (in @map).

View file

@ -0,0 +1,317 @@
<script setup>
/**
* region-dashboards.vue — Tab 2 ("Region Dashboards") of the regions-dashboard bundle.
*
* @description The dashboard half of the Driver's 2-tab Regions element: given a region (or a
* single country inside one), it summarises that selection — headline stats plus a comparison
* of its countries — using the SAME live people-groups endpoint and the SAME rollup fields as
* regions-list.vue (tab 1), so the two tabs can never disagree about a number.
*
* HOW IT LEARNS WHAT TO SHOW. This element is deliberately not wired to tab 1 directly. It takes
* its selection from its own `profile-config` (keys `regionSlug` / `countrySlug`) and ALSO listens
* for a scoped window event, 'doxa-regions-dashboard:select'. The composite (regions-mfe.vue)
* nests both tabs as sibling custom elements and forwards tab 1's `region-selected` /
* `country-selected` onto that event, scoped by instanceId — the same cross-pane seam
* upg-100-mfe.vue uses for the poster wall's 'doxa-upg-100-list:show-on-map'. Keeping the seam an
* EVENT (not a prop) is what lets each tab keep its own ProfileLoader/Pinia and still stay in sync.
*
* WITH NO SELECTION it shows every region compared against each other, so the tab is meaningful
* standalone (and on first paint, before anything has been clicked) rather than an empty state.
*
* SOURCE for the grouping logic: regions-list.vue's summarizeRegions(), itself a copy of DX-life's
* config/countries-meta.ts. Copied rather than imported for the same reason stated there — and
* kept field-for-field identical so both tabs roll up the same way.
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import BarChart from '@map/components/charts/BarChart.vue'
const props = defineProps({
profileConfig: { type: String, default: '' },
prayBaseUrl: { type: String, default: '' },
languageCode: { type: String, default: '' },
})
const cfg = computed(() => {
if (!props.profileConfig) return {}
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
})
const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl || 'https://pray.doxa.life')
const languageCode = computed(() => props.languageCode || cfg.value.languageCode || '')
const instanceId = computed(() => cfg.value.instanceId || '')
// ── state ───────────────────────────────────────────────────────────────────
const regions = ref([])
const loading = ref(true)
const loadFailed = ref(false)
// The current selection, as { regionSlug, countrySlug } — seeded from profile-config so a
// standalone embed can deep-link straight to one region's dashboard.
const sel = ref({
regionSlug: cfg.value.regionSlug || '',
countrySlug: cfg.value.countrySlug || '',
})
// ── identical field list + rollup to tab 1 ──────────────────────────────────
const FIELDS = 'country_code,wagf_region,latitude,longitude,population,adopted_by_churches,people_committed,engagement_status'
function toNumber(v) {
const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0)
return Number.isNaN(n) ? 0 : n
}
function slugify(name) {
return String(name || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/['\u2019.]/g, '')
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
const FULL_PRAYER_COVERAGE_COUNT = 100
function emptyStats() {
return { count: 0, population: 0, adopted: 0, engaged: 0, withPrayer: 0, withFullPrayer: 0, peopleCommitted: 0 }
}
function addToStats(stats, p) {
const committed = toNumber(p.people_committed)
stats.count++
stats.population += toNumber(p.population)
if ((p.adopted_by_churches ?? 0) > 0) stats.adopted++
if (p.engagement_status?.value === 'engaged') stats.engaged++
if (committed > 0) stats.withPrayer++
if (committed >= FULL_PRAYER_COVERAGE_COUNT) stats.withFullPrayer++
stats.peopleCommitted += committed
}
function summarizeRegions(posts) {
const countryGroups = new Map()
for (const p of posts) {
const code = p.country_code?.value
if (!code) continue
let g = countryGroups.get(code)
if (!g) {
g = { name: p.country_code.label, region: { value: '', label: '' }, stats: emptyStats() }
countryGroups.set(code, g)
}
if (!g.region.value && p.wagf_region?.value) g.region = p.wagf_region
addToStats(g.stats, p)
}
const countries = []
for (const [code, g] of countryGroups) {
countries.push({ slug: slugify(g.name) || slugify(code) || code.toLowerCase(), code, name: g.name, region: g.region, ...g.stats })
}
countries.sort((a, b) => a.name.localeCompare(b.name))
const regionSlugByCountry = new Map(countries.map((c) => [c.code, slugify(c.region.value) || 'other']))
const regionGroups = new Map()
for (const c of countries) {
const slug = regionSlugByCountry.get(c.code)
let g = regionGroups.get(slug)
if (!g) {
g = { value: c.region.value, label: c.region.label, countries: [], stats: emptyStats() }
regionGroups.set(slug, g)
}
g.countries.push(c)
}
for (const p of posts) {
const code = p.country_code?.value
if (!code) continue
const g = regionGroups.get(regionSlugByCountry.get(code))
if (g) addToStats(g.stats, p)
}
const out = []
for (const [slug, g] of regionGroups) {
out.push({ slug, value: g.value, label: g.label, countryCount: g.countries.length, countries: g.countries, ...g.stats })
}
out.sort((a, b) => (a.label || 'Other').localeCompare(b.label || 'Other'))
return out
}
function formatNumber(n) {
return Number(n || 0).toLocaleString('en-US')
}
// ── the selected subject ────────────────────────────────────────────────────
const selectedRegion = computed(() =>
regions.value.find((r) => r.slug === sel.value.regionSlug) || null
)
const selectedCountry = computed(() => {
if (!sel.value.countrySlug) return null
const r = selectedRegion.value
const pool = r ? r.countries : regions.value.flatMap((x) => x.countries)
return pool.find((c) => c.slug === sel.value.countrySlug) || null
})
// The subject line — a country if one was clicked, else the region, else everything.
const subject = computed(() => {
if (selectedCountry.value) {
const c = selectedCountry.value
return { kind: 'country', title: c.name, context: c.region?.label || 'Other', stats: c }
}
if (selectedRegion.value) {
const r = selectedRegion.value
return { kind: 'region', title: r.label || 'Other', context: `${formatNumber(r.countryCount)} countries`, stats: r }
}
const all = emptyStats()
for (const r of regions.value) {
all.count += r.count; all.population += r.population; all.adopted += r.adopted
all.engaged += r.engaged; all.withPrayer += r.withPrayer
all.withFullPrayer += r.withFullPrayer; all.peopleCommitted += r.peopleCommitted
}
return { kind: 'all', title: 'All Regions', context: `${formatNumber(regions.value.length)} regions`, stats: all }
})
// Headline tiles. Every one of these is a COUNT the rollup actually measured — no ratio is
// invented here that tab 1 could not also produce from the same records.
const tiles = computed(() => {
const s = subject.value.stats || emptyStats()
return [
{ label: 'People groups', value: formatNumber(s.count) },
{ label: 'Population', value: formatNumber(s.population) },
{ label: 'Engaged', value: formatNumber(s.engaged) },
{ label: 'With prayer cover', value: formatNumber(s.withPrayer) },
]
})
// The comparison bars: a region's countries against each other, or all regions against each
// other when nothing is selected. A country selection keeps its region's bars so the country
// stays legible IN CONTEXT rather than as one lone bar.
const chartRows = computed(() => {
const r = selectedRegion.value
const rows = r ? r.countries : regions.value
return rows
.map((x) => ({
label: x.name || x.label || 'Other',
value: x.count,
// Highlight the selected country inside its region's chart.
color: selectedCountry.value && x.slug === selectedCountry.value.slug ? '#3b463d' : '#92b195',
}))
.sort((a, b) => b.value - a.value)
.slice(0, 24)
})
const chartTitle = computed(() =>
selectedRegion.value
? `People groups by country — ${selectedRegion.value.label || 'Other'}`
: 'People groups by region'
)
// ── cross-pane seam: the composite forwards tab 1's selection here ──────────
const SELECT_EVENT = 'doxa-regions-dashboard:select'
function onSelect(e) {
const d = e?.detail || {}
// Scoped by instanceId exactly as upg-100-mfe's show-on-map seam is: an event carrying a
// DIFFERENT instance's id belongs to another embed on the same page and must be ignored.
if (d.instanceId && instanceId.value && d.instanceId !== instanceId.value) return
sel.value = { regionSlug: d.regionSlug || '', countrySlug: d.countrySlug || '' }
}
// ── data fetch ──────────────────────────────────────────────────────────────
async function loadRegions() {
const url = `${prayBaseUrl.value}/api/people-groups/list?fields=${FIELDS}&lang=${languageCode.value}`
loading.value = true
loadFailed.value = false
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
const posts = Array.isArray(data.posts) ? data.posts : []
regions.value = summarizeRegions(posts)
} catch (error) {
console.error('[region-dashboards] fetch error:', error)
loadFailed.value = true
regions.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
window.addEventListener(SELECT_EVENT, onSelect)
loadRegions()
})
onBeforeUnmount(() => {
window.removeEventListener(SELECT_EVENT, onSelect)
})
// Palette is DX-life material light, matching regions-list.vue tab-for-tab so swapping between
// the two tabs does not change the surface under the reader.
// NO BACKTICKS in this string, including comments (house style: one terminates the literal).
useShadowStyles(
':host{ display:block; width:100%; height:100%; min-height:320px; }' +
'.rd-root{ position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;' +
' --dx-surface:#f3f3f1; --dx-elevated:#fff; --dx-text:#2a332a; --dx-header-bg:#3b463d;' +
' --dx-header-text:#fff; --dx-muted:#6d766e; --dx-line:#d1d4cd; --dx-accent:#92b195;' +
' background:var(--dx-surface); color:var(--dx-text);' +
' font:16px/1.5 Poppins,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }' +
'.rd-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch;' +
' padding:20px; display:flex; flex-direction:column; gap:20px; }' +
'.rd-status{ padding:28px; text-align:center; color:var(--dx-muted); font-size:18px; }' +
'.rd-status.rd-failed{ font-weight:600; }' +
// Subject header: the same dark brand band tab 1 puts at the top of each region card, so the
// two tabs read as one element rather than two designs.
'.rd-subject{ flex:0 0 auto; border-radius:24px; overflow:hidden;' +
' background:var(--dx-header-bg); color:var(--dx-header-text); padding:20px 28px; }' +
'.rd-subject h2{ margin:0; font-size:34px; line-height:1.1;' +
' font-family:"Arial Narrow",BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;' +
' font-weight:400; font-stretch:condensed; }' +
'.rd-subject p{ margin:6px 0 0; font-size:16px; opacity:0.85; }' +
// Stat tiles.
'.rd-tiles{ flex:0 0 auto; display:grid; gap:12px;' +
' grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); }' +
'.rd-tile{ background:var(--dx-elevated); border-radius:18px; padding:18px 22px;' +
' border:1px solid var(--dx-line); }' +
'.rd-tile__value{ font-size:28px; font-weight:600; line-height:1.15; }' +
'.rd-tile__label{ margin-top:4px; font-size:14px; color:var(--dx-muted); }' +
// Chart card.
'.rd-chart{ flex:0 0 auto; background:var(--dx-elevated); border-radius:18px;' +
' border:1px solid var(--dx-line); padding:18px 22px; }' +
'.rd-hint{ flex:0 0 auto; color:var(--dx-muted); font-size:14px; }',
'region-dashboards'
)
</script>
<template>
<div class="rd-root">
<div class="rd-scroll">
<div v-if="loading" class="rd-status">Loading region dashboards…</div>
<div v-else-if="loadFailed" class="rd-status rd-failed" role="status">
Region data could not be loaded. This is a connection problem, not a filter.
</div>
<template v-else>
<div class="rd-subject">
<h2>{{ subject.title }}</h2>
<p>{{ subject.context }}</p>
</div>
<div class="rd-tiles">
<div v-for="t in tiles" :key="t.label" class="rd-tile">
<div class="rd-tile__value">{{ t.value }}</div>
<div class="rd-tile__label">{{ t.label }}</div>
</div>
</div>
<div class="rd-chart">
<BarChart
:series="chartRows"
:title="chartTitle"
unit="people groups"
color="#92b195"
/>
</div>
<p v-if="subject.kind === 'all'" class="rd-hint">
Choose a region or a country on the Regions List tab to focus this dashboard.
</p>
</template>
</div>
</div>
</template>

View file

@ -0,0 +1,6 @@
{
"type": "nested-elements-mfe",
"label": "Regions — List + Dashboards",
"composes": ["regions-dashboard/regions-list", "regions-dashboard/region-dashboards"],
"note": "The Driver's 2-tab Regions element. Composite MFE on the pane-wrapper layout, imitating my-upg-100-list/upg-100-mfe: a SELECT VIEW switcher (library/components/ViewSelect.vue) over Regions List at [0,0] and Region Dashboards at [1,0]. Clicking a region or country on tab 1 forwards the selection on 'doxa-regions-dashboard:select' and swaps to tab 2."
}

View file

@ -0,0 +1,182 @@
<script setup>
/**
* regions-mfe.vue — the regions-dashboard NESTED-ELEMENTS MFE (the 2-tab Regions element).
*
* @description The composite the Driver asked for: ONE element carrying the SELECT VIEW switcher
* over two panes — tab 1 "Regions List" (regions-list) and tab 2 "Region Dashboards"
* (region-dashboards). Clicking a region or a country on tab 1 swaps to tab 2 with that
* selection already applied.
*
* IMITATES upg-100-mfe.vue, does not reinvent it. That file is this repo's reference composite,
* and the Driver named it explicitly as the thing to copy. Same shape, tab for tab:
* • the pane-wrapper layout (buildPaneColumn + PANE_GRID_STYLES) supplies the scrolling column
* • the SAME View switcher in the header — now the SHARED library component ViewSelect.vue,
* which is that control extracted from upg-100-mfe rather than copied a third time
* • ONE shared instanceId across both panes, so the cross-pane selection event resolves here
* • paneHint:false, because the header switcher already says there is a second view
* • INHERITED_PAGE_KEYS forwarded explicitly (the cap-4 asset-path lesson from upg-100-mfe:
* a nested element has no leaf page of its own to hand it page-scoped config)
*
* THE TAB-1 -> TAB-2 WIRING. regions-list already dispatches 'region-selected' /
* 'country-selected' as composed, bubbling CustomEvents — it was built to be nested. This
* composite listens on its own stage, normalises the two payloads to { regionSlug, countrySlug },
* re-broadcasts them on the scoped 'doxa-regions-dashboard:select' window event that tab 2
* listens for, and scrolls to tab 2. That is the same seam upg-100-mfe uses for the poster
* wall's 'show-on-map', and it keeps each pane under its own ProfileLoader/Pinia.
*/
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 = 'regions-dashboard'
// One instanceId shared by BOTH panes, so the selection event scopes to THIS embed even when
// two Regions elements sit on the same page.
const instanceId = computed(() => cfg.value?.instanceId || 'regions-mfe')
const spec = computed(() => ({
id: 'regions-mfe',
tag: TAG,
config: { profile: 'regions-list', instanceId: instanceId.value },
panes: [
{ id: 'list', label: 'Regions List', at: [0, 0], config: { profile: 'regions-list' } },
{ id: 'dashboard', label: 'Region Dashboards', at: [1, 0], config: { profile: 'region-dashboards' } },
],
// The View 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)
const view = ref('list')
let column = null
let offScroll = null
// PAGE-LEVEL config keys a pane INHERITS from the host page — see the BUG-UPG100MFE-ASSET-PATH
// note in upg-100-mfe.vue: a cap-4 composite nests children in THIS document, so anything the
// generator bakes once per page has to be forwarded explicitly or the child falls back to its
// own default. Forwarded by KEY LIST, never by spreading the whole host config, so a pane's own
// `profile` / `instanceId` cannot be overwritten by the host's — that is what makes a pane a
// distinct view at all (pane.config is spread last and always wins).
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'languageCode', '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]
}
if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang
const pcfg = { ...inherited, ...spec.value.config, ...pane.config }
el.setAttribute('profile-config', JSON.stringify(pcfg))
return el
}
function go(id) {
view.value = id
column?.scrollTo(id)
}
// ── tab 1 -> tab 2: forward the selection, then swap the visible pane ───────
const SELECT_EVENT = 'doxa-regions-dashboard:select'
function forwardSelection(detail) {
window.dispatchEvent(new CustomEvent(SELECT_EVENT, {
detail: { ...detail, instanceId: instanceId.value },
}))
go('dashboard')
}
// A region heading (or its "See X dashboard" link) was clicked on tab 1.
function onRegionSelected(e) {
const d = e?.detail || {}
forwardSelection({ regionSlug: d.slug || '', countrySlug: '' })
}
// A country row was clicked on tab 1 — carry BOTH slugs so tab 2 can show the country in the
// context of its region rather than as one lone bar.
function onCountrySelected(e) {
const d = e?.detail || {}
forwardSelection({ regionSlug: d.region?.slug || '', countrySlug: d.slug || '' })
}
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
})
// regions-list's events are composed+bubbling, so they surface on this stage; listening HERE
// rather than on window keeps two Regions embeds on one page from hearing each other.
stage.value.addEventListener('region-selected', onRegionSelected)
stage.value.addEventListener('country-selected', onCountrySelected)
})
onBeforeUnmount(() => {
stage.value?.removeEventListener('region-selected', onRegionSelected)
stage.value?.removeEventListener('country-selected', onCountrySelected)
offScroll?.()
column?.destroy()
column = null
})
// Share the WHOLE MFE: this composite profile's own leaf page, TREE-RELATIVE.
const SHARE_EMBED_PATH = 'regions-dashboard/regions-mfe/index.html'
// NO BACKTICKS inside this CSS string, including in comments — one terminates the JS template
// literal and the build stops compiling (house style, AGENT-BRIEF.md).
useShadowStyles(PANE_GRID_STYLES + '\n' +
// SELF-SIZE. Every layer inside is absolute/height:100% (.rmfe -> .pane-col -> .pane), so the
// chain resolves to ZERO unless the HOST itself is sized — the "elements are not embedding"
// failure upg-100-mfe documents. Declaring :host makes this fill whatever slot it is mounted
// into, in every deploy method.
':host{ display:block; width:100%; height:100%; min-height:420px; }' +
// Material LIGHT palette only — no dark mode, no prefers-color-scheme override.
'.rmfe{ --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; }' +
// Header: same height/border as the shell; switcher centred, share at the right.
'.rmfe .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); }' +
// POSITIONING is the caller's job — ViewSelect ships its own metrics but no placement, so the
// absolute centring lives here exactly as it does in the shell and in upg-100-mfe.
'.rmfe .hdr .viewsel{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); }' +
// Stage: the pane-wrapper column mounts here (its .pane-col is absolute).
'.rmfe .stage{ position:relative; flex:1 1 auto; min-height:0; }' +
'.pane > .pane-mount > *{ position:absolute; inset:0; width:100%; height:100%; display:block; }',
'regions-mfe'
)
</script>
<template>
<div class="rmfe">
<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

@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>region-dashboards · regions-dashboard</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, regions-dashboard { position: fixed; inset: 0; width: 100%; height: 100%; 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…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-5QpUDIMXiWozhCtGWtXmkcbZPjuqaGMDKU5RrudhFcbMMWxfTI+OhnNbbhHAlYxv" 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 = {"profile":"region-dashboards","dataSource":"pray-tools","instanceId":"regions-dashboard-region-dashboards","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 = true;
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("regions-dashboard");
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>

View file

@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>regions-mfe · regions-dashboard</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<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, regions-dashboard { 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…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-5QpUDIMXiWozhCtGWtXmkcbZPjuqaGMDKU5RrudhFcbMMWxfTI+OhnNbbhHAlYxv" 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 = {"profile":"regions-mfe","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-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 = true;
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("regions-dashboard");
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>