doxa-maps/plugin-designs/doxa-maps-spa/doxa-maps-spa.vue
Doxa Maps edbd280d4d Research Gallery dev fallback: the manifest at the public root first, so the dev server serves children as the raw build
Reached as /public/<tree>/, the dev server treats a built bundle as source and
injects an import into its IIFE, so a child loaded as a classic script threw
"Cannot use import statement outside a module" on the staging page. The root
path is tried first; /public/<tree>/ stays last, for a plain static server.
Built trees resolve the manifest before these fallbacks and are unaffected.
2026-10-03 07:19:22 +00:00

1892 lines
126 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
doxa-maps-spa.vue — the Category-5 (nested-mfe-spa / app-shell) root component.
* @description The DX-maps app itself: one element whose vertical-tab rail nests every other
plugin as a tab, so a user reaches all the research maps in one place instead of a dozen
separate embeds. Curated sections encode the teaching order; anything else in the built
manifest is auto-discovered into Experimental, so a new plugin appears with no edit here.
ONE element, <doxa-maps-spa>, renders a VERTICAL-TAB layout that nests the other plugins
as its tabs. This IS the DX-maps app: the unified place a user opens to reach every research
map without getting confused by a dozen separate embeds.
TAB 1 = Big Picture — the cap-4 composite `dashboard-plus-grid`: the research dashboard with the
real people-groups grid stacked beneath it, scrolled as one element.
HOW EVERY TAB NESTS. ONE way, for every tab: inject that bundle's script once (guarded), wait for
its tag to REGISTER, then create the REAL custom element in an empty Vue-unmanaged slot. There
is NO iframe mode. Every plugin here is a compiled Vue custom element, so the SPA embeds the
element itself rather than a page wrapped around one — which is what lets the same bundle serve
all five deployment methods and compose further.
★ THE IFRAME MODE THAT USED TO LIVE HERE, AND WHY IT IS GONE (2026-09-22).
Every tab used to point a lazy <iframe> at that bundle's standalone leaf page, for two stated
reasons: (a) the map bundles each run a one-shot customElements.define, so sharing a document
risked a collision, and (b) window.MAP_TOKEN does not cross an iframe boundary, so a leaf's own
baked token kept each map independently correct.
Reason (a) was MEASURED away: the tree holds 19 distinct tags across 19 bundles with ZERO tags
claimed twice, so loading every bundle into one document registers 19 distinct tags and collides
with nothing. Two map elements were proven to coexist in one document. Reason (b) inverts once
the frames are gone — sharing THIS document means every tab reads the page's own token, so the
shell forwards tk/tokenUrl through profile-config instead (see mountElement).
What the iframes COST was the actual defect: each tab carried a HARDCODED, BUILT-TREE page path
('dashboard-plus-grid/dashboard-plus-grid/index.html'). Those pages do not exist under the dev
server, so every tab 404'd there, visibly, in the browser console. A path is
a deployment detail; the manifest's scriptUrl + element are identities that are correct in BOTH
trees. Tabs now name a bundle+profile and resolve the rest from the manifest.
WHAT REPLACED THE GUARD. The real cost of direct mounting is the browser's WebGL CONTEXT BUDGET,
so that is what the code now bounds (see HEAVY_ELEMENTS): heavy tabs are DESTROYED on deactivate
rather than hidden, keeping at most one live map context.
TREE BASE: the SPA must know where the tree lives to fetch manifest.json and each bundle's script.
index.js captures the running <script src=".../doxa-maps/doxa-maps-spa/doxa-maps-spa.js">
URL at IIFE load (document.currentScript) and provides its tree root (two levels up). A host
can override it with profile-config.treeBase or the tree-base="" attribute. Default falls
back to a same-tree relative guess so a plain drop still resolves.
NO re-import of any nested plugin's components and NO re-registration of a tag another bundle
owns — the only tag this bundle defines is <doxa-maps-spa> (see index.js). Each nested bundle
registers its own tag from its own script, and the tags are disjoint by naming discipline.
-->
<template>
<!-- The SHELL (top bar + vertical tab rail + content frame) is the ONE shared beautiful layout,
VerticalTabsLayout (library/layouts). This SPA only supplies the tab manifest + owns the panes:
the direct-element mount and the heavy-tab teardown machinery live in the default slot below,
driven by activeIndex. -->
<VerticalTabsLayout
:dir="uiDir"
:lang="uiLocale"
:labels="layoutLabels"
:home-href="homeHref || cfg.homeHref || defaultHomeHref"
:home-label="homeLabel || cfg.homeLabel || ''"
:title="headingText"
:nav-label="t('navLabel')"
:header="showHeader"
:groups="navGroups"
:active="activeKey"
@update:active="onRailSelect">
<!-- #brand: the DX-Maps logo mark, left of the title (a compass-star glyph, self-drawn SVG — no
external asset, offline/F-Droid safe; the accent green matches the admin shell). -->
<template #brand>
<span class="dms-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#92b195" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" />
<polygon points="12 6 14 12 12 18 10 12" fill="#92b195" stroke="none" />
<polygon points="6 12 12 10 18 12 12 14" fill="#92b195" opacity="0.55" stroke="none" />
</svg>
</span>
</template>
<template #meta>{{ t('meta', { n: tabs.length }) }}</template>
<!-- #toolbar: the i18n locale switcher (3-dots/flag). GATED by showLocaleSwitcher — the cap-5
DX-Maps SPA has NO standalone language dropdown by default; it INHERITS its language from the
parent page via the `language` prop (see effectiveLocale). The dropdown only shows when the
header is ON (a host explicitly opts the SPA into its own chrome). Reuses
app-wrapper-material/languages.js — persists the choice + re-sets the mounted tab-1 element's
profile-config.locale (languages.js's contract). No routing, no dep. -->
<template #toolbar v-if="showLocaleSwitcher">
<div class="dms-locale">
<button type="button" class="dms-locale-btn" :aria-expanded="String(localeOpen)"
aria-label="Change language" @click="localeOpen = !localeOpen">
<span class="dms-flag">{{ activeLang.flag }}</span>
<span class="dms-locale-name">{{ activeLang.nativeName }}</span>
<span class="dms-dots" aria-hidden="true">⋮</span>
</button>
<ul class="dms-locale-menu" v-if="localeOpen" role="listbox">
<li v-for="l in switcherLangs" :key="l.code" role="option"
:aria-selected="String(l.code === locale)">
<button type="button" class="dms-locale-opt" :class="{ active: l.code === locale }"
@click="setLocale(l.code)">
<span class="dms-flag">{{ l.flag }}</span>
<span class="dms-locale-name">{{ l.nativeName }}</span>
<span class="dms-locale-en">{{ l.name }}</span>
</button>
</li>
</ul>
</div>
</template>
<!-- THE LANGUAGE MENU OF A HEADER-OFF SHELL: the end of the layout's phone bar, and the bottom of the
desktop rail. ShellLanguageMenu first asks the host (a cancelable doxa-language-request). If the
host takes it, the host sets `language` and this gallery follows. If not, it emits 'switch' and the
gallery switches itself (pickLocale). `current` is uiLocale, so the select always has a matching
option. -->
<template #phone-end><ShellLanguageMenu :current="uiLocale" @switch="pickLocale" /></template>
<template #rail-end="{ collapsed }"><ShellLanguageMenu :current="uiLocale" :collapsed="collapsed" @switch="pickLocale" /></template>
<!-- EVERY tab mounts its plugin's REAL custom element — there is no iframe mode. The tag is
created imperatively into an EMPTY, Vue-unmanaged slot div (:ref, no children) after that
bundle's script is injected once, so Vue never fights the custom element's own shadow DOM.
The loading/error hint is a SEPARATE overlay sibling, so clearing the slot with innerHTML
can never remove a Vue-managed node.
TWO LIFETIMES, and the difference is the WebGL context budget, not cosmetics:
LIGHT rows (v-show) stay mounted once loaded, so switching back is instant and state
survives. This is deliberate — do not "simplify" it to v-if.
HEAVY rows (v-if, t.heavy — anything that owns or NESTS a map) are DESTROYED on
deactivate. Removing the host node runs the element's disconnected lifecycle
(onBeforeUnmount → map.remove()), which is what actually releases the GPU context.
Without it every map ever visited would hold a live context until the browser silently
dropped the OLDEST one and an already-visited tab went blank with NO error. -->
<section
v-for="(t, i) in tabs" :key="t.id" class="dms-pane"
:ref="el => { if (el) paneEls[i] = el; else delete paneEls[i] }"
v-show="i === activeIndex" :hidden="i !== activeIndex"
:data-doxa-help="tabHasHelp(t) ? '' : null" data-doxa-gallery="">
<template v-if="!t.heavy || i === activeIndex">
<div v-if="showsMobileNotice(t.id)" class="dms-mobile-notice" role="status">
<span>{{ largerScreenText }}</span>
<button type="button" class="dms-mobile-notice-x" :aria-label="tr('dismiss')" @click="dismissMobileNotice(t.id)">×</button>
</div>
<div class="dms-slot" :ref="el => setSlot(i, el)"></div>
<!-- The tab's "what can this do for you?" button (library MapInfo), from src/tabInfo.js. It sits
over the pane, top-right; profile-config info:false removes it. -->
<MapInfo v-if="infoFor(t)" :key="'info-' + t.id" v-bind="infoProps(t)" :style="infoStyle(t)" :placement="infoPos(t).placement || 'top-right'" :class="{ 'dms-info-raise': infoPos(t).raise }"
:assets-base="assetsBase" :locale="effectiveLocale" />
<div v-if="t.state === 'loading'" class="dms-hint">{{ tr('loading', { label: tabLabel(t) }) }}</div>
<div v-else-if="t.state === 'error'" class="dms-hint err">
{{ tr('loadFailed', { label: tabLabel(t) }) }}<br><small>{{ t.error }}</small>
</div>
<!-- ★ THE FOURTH STATE, and the one that used to render NOTHING. A tab whose scriptUrl the
manifest never supplied sits in state 'idle' forever: not loading, not errored, just an
empty slot — the grey pane that kept coming back. Covering it here means a
missing manifest now READS as a failure instead of looking like a blank app. -->
<div v-else-if="t.state === 'idle' && !t.scriptUrl && manifestError" class="dms-hint err">
{{ tr('loadFailed', { label: tabLabel(t) }) }}<br>
<small>{{ manifestError }}</small><br>
<small>{{ tr('manifestHint') }}</small>
</div>
</template>
</section>
<!-- THE DEVICE-RULE NOTICE — fires when a mobile user clicks the disabled Big Picture row (see
onSelect's intercept). A transient toast rather than a modal: the point is to explain why
nothing switched, not to block the app. Sibling of the panes, not inside one, so it survives
whichever tab is currently active. -->
<div v-if="disabledNotice" class="dms-notice" role="status">{{ disabledNotice }}</div>
<!-- THE EXPERIMENTAL MAP'S "?" — what a shelf map is for and how to use it (src/experimentalHelp.js).
The button is the MAP'S OWN "?" control, in its control column with zoom and fullscreen: it
dispatches a composed doxa:help event that this app answers (onHostedHelp). A pane whose tab
has text carries data-doxa-help, and a map's hosted "?" shows only under such a pane, so a map
with no text shows no button rather than an empty dialog. Modal: focus moves to Close, Tab
stays inside, Escape or Close returns focus to the "?" that opened it, and switching tab closes it. -->
<div v-if="helpOpen && activeHelp" class="dms-help-scrim" @click.self="closeHelp"
@keydown.esc.stop.prevent="closeHelp">
<div class="dms-help" role="dialog" aria-modal="true" aria-labelledby="dms-help-title"
@keydown.tab.prevent="$refs.helpClose && $refs.helpClose.focus()">
<h2 id="dms-help-title" class="dms-help-title">{{ tabLabel(tabs[activeIndex]) }}</h2>
<p class="dms-help-what"><strong>{{ activeHelp.what }}</strong></p>
<p class="dms-help-how">{{ activeHelp.how }}</p>
<button ref="helpClose" type="button" class="dms-help-close" @click="closeHelp">{{ tr('close') }}</button>
</div>
</div>
<!-- ★ BACK TO TOP, BESIDE THE VIEW-LIST CIRCLE. The gallery owns both the circle
and the scroller, so this needs no shadow-root walking: the target is the ACTIVE .dms-pane,
which measured as the real scroller (clientHeight 712, scrollHeight 12,373) while the
document does not scroll and no .vtl-host exists in this composition.
Horizontal placement comes from --map-fab-beside, which VerticalTabsLayout publishes ONLY
while its circle renders (12px inset + 44px button + 8px gap), so the arrow sits next to the
circle rather than on it — and where there is no circle it falls back to the plain inset
instead of leaving a gap. Reading the var means the circle's geometry stays stated once, by
the component that owns it.
BackToTopButton refuses to appear when its target does not scroll (it tests scrollTop AND
scrollHeight), so a short pane shows nothing rather than a button that does nothing. -->
<BackToTopButton :target="activePaneEl" :labels="{ backToTop: tr('backToTop') }" />
</VerticalTabsLayout>
</template>
<script>
import { defineComponent, inject } from 'vue'
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
import ShellLanguageMenu from '@map/components/ShellLanguageMenu.vue'
import BackToTopButton from '@map/components/BackToTopButton.vue'
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/layouts/app-wrapper-material/languages.js'
import { translate, LOCALES, hasKey } from './src/strings.js'
import { tStatic } from '@map/i18n/index.js'
import { helpFor } from './src/experimentalHelp.js'
import { TAB_INFO } from './src/tabInfo.js'
import MapInfo from '@map/components/info/MapInfo.vue'
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
import { readViewKeys, writeViewKeys, onAddressChange, urlOwnerOf, ownAddress } from '@map/composables/useViewAddress.js'
// THE HOST PAGE'S LANGUAGE, by the same ladder every nested map already uses (library/i18n
// detectLocale): ?lang= / ?locale= on the page URL, then <html lang>. '' when the page says nothing.
// NOT navigator.language: the gallery used to fall back to the BROWSER's language and force it onto
// every child through profile-config.locale — measured 2026-09-25, a Spanish page (<html lang="es">)
// viewed in a German browser rendered its maps in German and its own rail in English.
function detectHostLocale() {
try {
const q = new URLSearchParams(location.search)
const fromUrl = (q.get('lang') || q.get('locale') || '').split('-')[0].toLowerCase()
if (LOCALES.includes(fromUrl)) return fromUrl
} catch { /* no window */ }
try {
const fromHtml = (document.documentElement.lang || '').split('-')[0].toLowerCase()
if (LOCALES.includes(fromHtml)) return fromHtml
} catch { /* no document */ }
return ''
}
// The product's section/folder nav model, ported from app-wrapper-material/nav.js (the verified
// source of the exact section list). Each entry is a GROUP for VerticalTabsLayout; each
// `tab` names a REAL TAB_DEFS id so clicking a row still drives the SAME mount machinery (onSelect →
// findIndex(t.id === key)). Sections/folders + icons are authored here; rows whose bundle does not
// exist in this SPA are OMITTED (never paint a dead nav row — nav.js's own navForTarget prunes empty
// groups the same way). `folder:true` (Experimental) renders collapsible, starting closed.
const NAV_MODEL = [
// ── THREE SECTIONS: Dashboards, Prayer Mobilization and Gospel Research, each holding its own maps.
// This comment explains how the code keeps that shape, not why the shape was chosen.
//
// A PRIOR CUT REMOVED DASHBOARDS ENTIRELY (see git history / the spec's "duplicate-key trap"),
// because Big Picture had been declared TWICE — once as a one-row Dashboards section and once as
// Prayer Mobilization's first rung — and VerticalTabsLayout keys its tab-ref map and `active`
// highlight BY ID, so two rows sharing one id painted both active at once and stalled keyboard
// nav. That was a REGRESSION, not a fix for the underlying constraint. The constraint (no id
// painted twice) is real and still enforced (see dedupeRows + the assertNoDuplicateTabKeys
// dev-time gate below); the fix is that Dashboards now owns TWO DISTINCT rows of its own — Big
// Picture never appears in Prayer Mobilization again — so it is not a one-row section that begs
// to be folded away.
{ key: 'dashboards', label: 'Dashboards', i18n: 'sec.dashboards', icon: 'chart',
tabs: [
// 1. Big Picture — desktop default. Present on EVERY width (the device rule is "same tab
// order everywhere"), but DISABLED on mobile rather than hidden — see isDesktopOnly /
// onSelect's intercept and the `disabled`-row note above navGroups. Clicking it on mobile
// shows a message instead of switching; it never disappears from the rail.
{ id: 'dashboard-plus-grid', icon: 'chart' },
// 2. Simple Research — mobile default; RENAMED from "Research Map" and RECATEGORISED here as a
// dashboard (it used to live as Prayer Mobilization's mobile-only rung 1). It is the research
// map + People Groups grid composite — "the mobile dashboard" per the spec — and it stays
// reachable on desktop too, as the second dashboard row.
{ id: 'research-map-plus-grid', icon: 'map' },
// 3. Regions — the third dashboard, beside Big Picture and Simple Research.
// Seven plugins across three sections was the ask; this row is the seventh.
//
// ★ WHAT SHIPS TODAY IS TAB 1 OF 2. regions-dashboard currently builds a single profile,
// `regions-list` (verified in the manifest: profiles ['regions-list'], element
// 'regions-dashboard') — the WAGF region list refactored from DX-life's /regions page,
// which emits region-selected / country-selected. The second profile, the scrollable
// region/country detail dashboard those events will drive, is a separate card and lands as
// an ADDITIONAL profile in the same bundle; this row needs no edit when it does, because
// the row names the BUNDLE and the shell resolves its profile from the manifest.
//
// Listing it now rather than waiting for tab 2: a working list that reaches the rail is
// worth more than a complete feature nobody can open, and its absence was the one real gap
// between the seven planned plugins and the six that shipped.
{ id: 'regions-dashboard', icon: 'map' },
] },
// Prayer Mobilization — now TWO rows only: the onboarding ramp's steps 2 and 3. Big Picture (former
// step 1) moved to Dashboards above; Gospel Resources (former step 4) moved to Gospel Research below
// per the spec's table. UPG-100 BEFORE Prayer Progress is a deliberate product order: to someone
// being mobilized to pray, the UPG-100 list is the NEW thing, so they meet it first — hand the
// newcomer the concrete new artifact, THEN ask them to pray it.
//
// THE POSTER ROWS (2026-09-30): the section now runs Prayer Progress, UPG 100 List Posters, Country Posters,
// then Poster Design. The same poster plugins also live in the Posterverse shell; each is ONE bundle, mounted
// by whichever shell opens it, under the same tab ids in both. A row whose bundle is not in the manifest is
// dropped without a word (resolveTabsFromManifest), so a row lands only after its bundle does.
{ key: 'prayer-mobilization', label: 'Prayer Mobilization', i18n: 'sec.prayer', icon: 'heart',
tabs: [
// Prayer Progress, UPG 100 List Posters, Country Posters, then Poster Design: the published order, fixed.
{ id: 'prayer-map', icon: 'embed' },
{ id: 'upg-100', icon: 'list' },
{ id: 'country-posters', icon: 'poster' },
{ id: 'poster-design', icon: 'grid' },
] },
// Gospel Research — RENAMED section (was "Research"). Cities with Internet then Gospel Resources,
// per the spec's table (rows 5-6). The plain, uncomposited "Research Map" tab (doxa-research-map)
// is no longer one of the six curated tabs — its research-map+grid composite now IS Dashboards row
// 2 (Simple Research) — so the standalone map is left OUT of every curated section and falls
// through to Experimental automatically (see curatedBundles/experimentalTabsFromManifest); that is
// correct, not an omission.
{ key: 'gospel-research', label: 'Gospel Research', i18n: 'sec.gospel', icon: 'search',
tabs: [
{ id: 'cities-internet', icon: 'wifi' }, // renamed label: "Cities with Internet"
{ id: 'gospel-resources', icon: 'book' },
] },
// Experimental — a collapsible FOLDER at the bottom (nav.js: kind 'folder', open:false). Starts
// CLOSED; click the heading to open.
//
// ★ GENERIC BY CONSTRUCTION — no hardcoded row list. Its `tabs` is deliberately EMPTY here and is
// filled at runtime by discoverExperimental() from the BUILT manifest.json: every bundle that
// exists in the tree but is not placed in a named section above lands here automatically. Add a
// new plugin to plugin-designs/ and it appears in this folder with ZERO edits to this file.
// WHY this matters: a hardcoded list fails SILENTLY — the next plugin author ships a bundle, it
// builds fine, and it is simply invisible in the app with no error to notice. Same principle as
// the data bundler: handle every plugin generically, or the next author gets nothing. The named sections above stay curated (they encode the teaching order); Experimental
// is the automatic catch-all so nothing can go missing.
{ key: 'experimental', label: 'Experimental', i18n: 'sec.experimental', icon: 'flask', folder: true, open: false, tabs: [] },
]
// Bundles that are NOT candidates for auto-discovery into Experimental, with the reason each is
// excluded. Everything else the manifest reports becomes an Experimental tab automatically.
// - doxa-maps-spa : this shell itself (nesting the app inside its own tab = infinite regress)
// - plugin-studio : the authoring/dev tool, not an end-user map
// - template-bundle: the copy-me scaffold for new plugin authors, not real content
// - people-groups-grid / research-dashboard / big-picture: the PARTS that are already composed
// into the ONE `dashboard-plus-grid` Big Picture tab — surfacing them separately is the exact
// redundancy that was removed on purpose: one dashboard, not several.
// ── DEVICE RULES (SPEC-MOBILE-FLAG) ─────────────────────────────────────────────────────────────
// A map that is not phone-ready says so ONCE, in its own <profile>.meta.json: "mobile": "desktop-only"
// or "larger-screens". generate-tree carries the flag into the manifest, and this shell reads it from
// there (mobileFlagsFromManifest), so the Gallery and the map's standalone leaf page follow the same
// declaration and no id list lives here. On a narrow screen:
// desktop-only the row stays in the rail, disabled with a "desktop only" badge; a tap explains why
// instead of mounting (Big Picture was the first, and still works exactly that way)
// larger-screens the map OPENS, with a "not built for mobile" badge and a dismissible notice in the
// pane: reachable, and honest about where it works
// check:mobile-gating holds the vocabulary, the leaf pages and this shell to that one source.
function mobileFlagsFromManifest(manifest) {
const flags = {}
const bundles = (manifest && Array.isArray(manifest.bundles)) ? manifest.bundles : []
for (const b of bundles) {
for (const p of (Array.isArray(b && b.profiles) ? b.profiles : [])) {
if (p && (p.mobile === 'desktop-only' || p.mobile === 'larger-screens')) flags[b.bundle + '/' + p.profile] = p.mobile
}
}
return flags
}
const NOT_EXPERIMENTAL = new Set([
'doxa-maps-spa',
'plugin-studio',
'template-bundle',
'people-groups-grid',
'research-dashboard',
'big-picture',
// roundup ruling (EXPERIMENTAL-MAPS-ROUNDUP.md, 2026-09-24): scaffolding and authoring
// tooling, not maps -- the studio still surfaces them for developers.
'demo-map',
'golden-path-check',
// doxa-research-map is the PART already composed into the Simple Research tab
// (research-map-plus-grid) -- same redundancy rule as research-dashboard/big-picture.
'doxa-research-map',
// stack-demo is broken and DEFERRED; a broken tab in the giveaway shelf reads as
// product damage. It returns when someone fixes it (a tracked issue).
'stack-demo',
// posterverse is the OTHER capability-5 shell (the poster plugins). Shown here it would be a
// whole second rail inside this pane: a shell does not nest a shell, for the same reason this
// shell excludes itself above.
'posterverse',
// bible-posters lives on POSTERVERSE's Experimental shelf, and only there: one poster plugin, shown
// once, in the shell that gathers the poster plugins.
'bible-posters',
// poster-sections is a TEMPLATE plugin: the <dxm-poster-section> element other plugins and sites embed
// (the Regions list, DX Life's region pages). It stays in Plugin Studio, not on a reader's shelf.
'poster-sections'
])
// ── EXPERIMENTAL ORDER = KINGDOM IMPACT, not the alphabet ────────────────────────────────
// The roundup charter orders the folder by "potential to get people TO the unreached groups
// and help them meet and understand Jesus", finalized 2026-09-24 AFTER reading every map's
// rendered output (EXPERIMENTAL-MAPS-ROUNDUP.md has the per-map reasoning). Rank is
// EDITORIAL, the same class of decision as the curated TAB_DEF above -- which is why it
// lives here and not in per-bundle meta: the manifest deliberately strips meta config, and
// an order is a statement about the whole shelf, not about one bundle. Unranked bundles
// still appear (discovery is never gated by this) -- they sort after the ranked, A-Z.
// ★ THE TOP THREE ARE A PRODUCT DECISION (2026-09-28): Prayer Walk Planner, then Gospel × Internet,
// then UPG Neighbor, a map on its way up. The rest keep their order.
const EXPERIMENTAL_RANK = {
'prayer-walk-planner': 1, // feet on streets: zones -> printed one-page walking maps
'gospel-internet': 2, // the unified dashboard: language tree, bounded/centered sets, map
'upg-neighbor': 3, // near reached neighbors, both directions ("who goes, to whom")
'key-cities': 4, // where the unreached physically live
'all-people-groups': 5, // the census view that feeds everything else
'doxa-vision-card': 7, // vision framing (6 was country-posters, now a Prayer Mobilization row)
}
// The tab manifest. Each entry CURATES one tab: which bundle+profile it shows, in what order, under
// what human label. Every tab nests its plugin's REAL custom element — there is no iframe mode.
//
// ★ WHAT A TAB DECLARES, AND WHAT IT DELIBERATELY DOES NOT. A tab names a `bundle` and a `profile`
// — two IDENTITIES the manifest can resolve — and never a PATH. The script URL and the element tag
// are looked up from manifest.json at runtime (see resolveTabsFromManifest), because the manifest
// is the one artifact that is correct in BOTH trees: it hands "/plugin-designs/<b>/index.js" under
// the dev server and "./<b>/<b>.js" under the built tree.
//
// ★ THE 404 CLASS THIS REPLACED (measured 2026-09-22 in a real browser). Every
// entry used to carry a hardcoded `leaf:` — a BUILT-tree page path, e.g.
// leaf: 'dashboard-plus-grid/dashboard-plus-grid/index.html'
// leaf: 'my-upg-100-list/upg-100-mfe/index.html'
// and each tab pointed an iframe at it. Under the dev server those pages do not exist, so the
// console filled with
// GET /__plugin-gallery/dashboard-plus-grid/dashboard-plus-grid/index.html?locale=en 404
// GET /__plugin-gallery/my-upg-100-list/upg-100-mfe/index.html?locale=en 404
// The dev MANIFEST was clean the whole time (zero profiles carry a leaf-style iframeUrl); the 404s
// came only from this hardcoded list. A path is a deployment detail; an identity is not. Naming
// identities is what makes ONE dev server show every plugin, which was the actual goal.
//
// A second, sharper symptom proved the paths were built-tree fossils rather than merely stale:
// `prayer-map` pointed at 'doxa-simple-map/doxa-simple-map/prayer/index.html', and the dev
// manifest has NO `prayer` instance at all — only the `doxa-simple-map` profile. That path could
// never resolve under dev no matter how the base was joined.
//
// `profile` is LOAD-BEARING, not decoration: gospel-resources ships four profiles (charts /
// data-tables / resources-list / resources-map) through the SAME tag <gospel-resources-map>, so the
// tag alone cannot say which view to paint. profile-config.profile is what selects it.
const TAB_DEFS = [
{
// THE ONE DASHBOARD ("Big Picture"): research-dashboard + people-groups-grid compiled
// into ONE element on a single scroll-page — scroll down past the dashboard to the grid. Replaces the
// former separate `big-picture` and `research-dashboard` tabs (removed as redundant).
id: 'dashboard-plus-grid', label: 'Big Picture', sub: 'dashboard + people-groups grid, stacked', emoji: '🌍',
bundle: 'dashboard-plus-grid', profile: 'dashboard-plus-grid'
},
{
id: 'research-map', label: 'Research Map', sub: 'prayer · engagement · regions', emoji: '🗺️',
bundle: 'doxa-research-map', profile: 'research-map'
},
{
// RENAMED (spec): "Research Map" → "Simple Research", and RECATEGORISED as a Dashboards row (the
// mobile-default dashboard). An earlier design swapped elements by mobile vs desktop inside one
// plugin; it was reversed, because two separate capability-4 plugins are simpler. So there are
// two cap-4 composites,
// neither aware of the other — this one and 'dashboard-plus-grid' above — and the shell decides
// which is AUTO-SELECTED by width (see firstVisibleIndex/isDesktopOnly), never which is
// SHOWN: per the device rule both rows are always in the rail. Both composites stay independently
// embeddable, which a self-swapping element could not be (an embedder at a fixed size could never
// ask for one).
//
// NOTE the `id` stays 'research-map-plus-grid': the id is the stable join key between NAV_MODEL
// and TAB_DEFS, so only the human-facing LABEL/sub change on a rename — never the id.
//
// Same cap-4 tier as its desktop sibling: research-map-plus-grid.meta.json declares
// {"type":"nested-elements-mfe"}, verified against the built manifest.
id: 'research-map-plus-grid', label: 'Simple Research', sub: 'research map + people-groups grid', emoji: '🗺️',
bundle: 'research-map-plus-grid', profile: 'research-map-plus-grid'
},
{
// RENAMED: "Prayer Map" → "Prayer Progress", and it sits UNDERNEATH UPG-100 List. Of the two
// candidate names ("Prayer Progress Map" / "Prayer Progress") the shorter won — the rail is
// narrow and "Progress" already implies the map. NOTE the `id`
// stays 'prayer-map': the id is the stable join key between NAV_MODEL and TAB_DEFS, so only the
// human-facing LABEL changes. Renaming the id would break the nav lookup for no user-visible gain.
//
// `prayer` is an INSTANCE of the parameterized doxa-simple-map bundle, present in the BUILT tree
// and absent from the dev manifest. It is declared as an optional `instance` rather than baked
// into a path: where it exists the tab renders that preset, and where it does not the tab falls
// back to the bundle's own profile instead of 404ing. See resolveTabsFromManifest.
id: 'prayer-map', label: 'Prayer Progress', sub: 'simple world prayer map', emoji: '🙏',
bundle: 'doxa-simple-map', profile: 'doxa-simple-map', instance: 'prayer'
},
{
// RENAMED (spec): "Cities & Internet" → "Cities with Internet" — the Gospel Research section's
// first row. NOTE the `id` stays 'cities-internet' for the same stable-join-key reason as above.
id: 'cities-internet', label: 'Cities with Internet', sub: 'connectivity of key cities', emoji: '📡',
bundle: 'cities-internet', profile: 'connectivity-map'
},
{
id: 'gospel-resources', label: 'Gospel Resources', sub: 'resources by place', emoji: '📖',
bundle: 'gospel-resources', profile: 'resources-map'
},
{
// The third Dashboards row. `profile` names the ONE profile this bundle currently builds;
// when the region-detail profile lands it becomes tab 2 INSIDE this element, so this entry
// still does not change — the shell resolves the profile from the manifest either way.
// ★ MOUNT THE COMPOSITE, NOT THE LEAF. The Regions tab is a capability-4 plugin but rendered
// like a capability-1 one, and the cause was here rather than in the regions bundle: regions-mfe (the cap-4
// nested-elements-mfe composing regions-list + region-dashboards, with the shared Select View
// strip) HAD shipped, but this entry still named the bare `regions-list` leaf — the cap-1 half.
// So the shell rendered one flat list with no tab strip while the composite sat unused.
// The note above anticipated exactly this ("when the region-detail profile lands it becomes tab
// 2 INSIDE this element") but assumed the shell would follow automatically; it does not — the
// profile named here is the one that mounts. Compare the UPG-100 entry below, which correctly
// names `upg-100-mfe` rather than `upg-100-list`: same shape, and it is why that tab has tabs.
id: 'regions-dashboard', label: 'Regions', sub: 'regions and their countries', emoji: '🌐',
bundle: 'regions-dashboard', profile: 'regions-mfe'
},
{
id: 'upg-100', label: 'UPG100 Posters', sub: 'my unreached list + posters', emoji: '📋',
bundle: 'my-upg-100-list', profile: 'upg-100-mfe'
},
{
// The capability-4 composite, as with Regions and UPG-100 above: country-posters also ships the
// single views countries-map and country-poster-wall, and naming one of those would mount a
// part without the view strip that joins them.
id: 'country-posters', label: 'Country Posters', sub: 'a poster for every country', emoji: '🗺️',
bundle: 'country-posters', profile: 'country-posters-mfe'
},
{
// The capability-4 composite again (Styles + Sizes under one view strip); poster-styles and poster-sizes
// are its single views. It draws posters, not a map, so it holds no WebGL context and is not HEAVY.
id: 'poster-design', label: 'Poster Design', sub: 'every poster style and size', emoji: '🎨',
bundle: 'poster-design', profile: 'poster-design-mfe'
},
// ── NOTE: there are deliberately NO hardcoded Experimental entries here. Every built bundle not
// named above is discovered from manifest.json at runtime and appended as an Experimental tab
// (see discoverExperimental + NOT_EXPERIMENTAL). That is what keeps a NEW plugin visible with
// zero edits to this file.
]
// THE SPEC'S REQUIRED GATE, run once at module load — see assertNoDuplicateTabKeys below for why this
// exists as a LOUD check on top of dedupeRows' silent runtime safety net. `function` declarations
// hoist, so this call is valid even though the function body is defined later in the file.
assertNoDuplicateTabKeys(NAV_MODEL)
// MAP ELEMENTS — the rows this shell DESTROYS on deactivate rather than hiding.
//
// ★ THIS IS A WebGL CONTEXT BUDGET, NOT A STYLE CHOICE. The browser allows ~8-16 live WebGL contexts.
// Exceeding it prints "Too many active WebGL contexts" and silently kills the OLDEST one —
// getContext() still returns a context and NO event fires on the new canvas, so a previously
// visited map simply goes blank with no error anywhere. Keeping every pane alive with v-show (the
// right default for light rows, since state survives and switching is instant) would walk straight
// into that ceiling. So heavy rows use v-if and are torn down, bounding the document to ~1 live map.
//
// ★ MEASURED, NOT GUESSED — and NOT a copy of the old iframe list. A row is heavy if its element
// holds a live WebGL CONTEXT, which a TAG NAME CANNOT TELL YOU: the cap-4 composites embed maps as
// nested CHILDREN, so <dashboard-plus-grid> and <research-map-plus-grid> own a context while
// looking like plain composites. Counting canvases inside the ACTIVE PANE reports 1 and looks
// clean; counting them DOCUMENT-WIDE is what exposes the real total. Measure the document.
// Mirrors plugin-studio.vue's HEAVY_ELEMENTS, which was derived the same way over the same tree.
const HEAVY_ELEMENTS = new Set([
// map elements proper
'doxa-research-map', 'doxa-simple-map', 'cities-internet-map', 'gospel-resources-map',
'my-upg-100-list-map', 'doxa-map', 'doxa-maps-spa',
// composites that NEST a map, so they own a context too (measured, see above)
'dashboard-plus-grid', 'research-dashboard-map', 'demo-map', 'all-people-groups',
'country-posters-map', 'key-cities-map', 'research-map-plus-grid',
// up to TWO contexts: its map, plus the language-tree whiteboard once that is opened
'gospel-internet-board'
])
// SCROLL-STACK ELEMENTS — the rows this shell mounts WITHOUT the fixed-viewport inline style
// (the grid under a composite could not be scrolled to). Every mounted tab used to get
// `position:absolute;inset:0;height:100%` unconditionally, which is correct for a single
// FIXED-VIEWPORT map (it fills the pane exactly, scrollHeight===clientHeight, no scrollbar) but
// traps a SCROLL-STACK composite: `dashboard-plus-grid` and `research-map-plus-grid` are built on
// the shared StackLayout (library/layouts/StackLayout.vue), which is natural-height, flow-root,
// NO fixed height by design — the whole point is that the host page grows and scrolls through it.
// Pinning that element to `inset:0` forces it back to exactly one viewport tall regardless of its
// real content height, which is the trap: a tall composite (research-dashboard + people-groups
// grid stacked, ~2600px of real mounted content) had nowhere for that height to go, because BOTH
// the element AND its container (.dms-pane/.dms-slot) were absolutely positioned with no scroller.
// The two composites here are exactly the two bundle ids that use StackLayout as their OWN root —
// same identification method as HEAVY_ELEMENTS (measured against the tree, not guessed), and it is
// not a coincidence that both entries already appear in that set too (they also nest a map).
// bless-india-posters is a natural-height page of poster cards (its owner's mount note): pinned, the grid
// would be trapped in one viewport, so it scrolls in the pane like the two composites.
const SCROLL_ELEMENTS = new Set(['dashboard-plus-grid', 'research-map-plus-grid', 'bless-india-posters'])
// The set of bundle ids the CURATED sections above already place, derived from NAV_MODEL + TAB_DEFS
// rather than hand-written. Deriving it means the "is this already placed?" answer can never drift
// out of sync with the actual rail — the classic failure mode of a parallel hardcoded list.
function curatedBundles() {
const byId = Object.fromEntries(TAB_DEFS.map(t => [t.id, t]))
const out = new Set()
for (const g of NAV_MODEL) {
if (g.key === 'experimental') continue // the catch-all places nothing by definition
for (const r of g.tabs || []) {
// Every curated tab now declares its `bundle` outright. It used to be recovered from the first
// segment of the hardcoded `leaf` path — a second reason those paths were load-bearing, and a
// silent one: a tab with a wrong leaf ALSO mis-reported which bundle it had placed, which would
// let an already-curated bundle reappear in Experimental.
const t = byId[r.id]
if (t && t.bundle) out.add(t.bundle)
}
}
return out
}
/**
* THE RAIL'S ONE ENUMERATION RULE — one row per TAB ID, first declaration wins.
*
* ★ THE RULE, stated once so the next plugin author inherits it deliberately rather than by accident:
* a BUNDLE contributes exactly ONE rail row. Its profiles (gospel-resources ships charts /
* data-tables / resources-list / resources-map; big-picture ships instances) are alternate VIEWS of
* that one map and are reachable INSIDE the pane — they are NOT promoted to rail rows. The rail is
* an onboarding ramp, not an inventory: per-profile rows would add four rows for gospel-resources
* alone and bury the teaching order the sections above encode.
*
* WHY A FUNCTION AND NOT JUST A CAREFULLY-WRITTEN NAV_MODEL: sections are allowed to DECLARE the same
* tab twice on purpose (Gospel Resources is both the prayer ramp's "then act" step and a research
* surface). Declaring twice is expressive; RENDERING twice is a bug. This keeps the first declaration
* — which is what makes the curated order authoritative — and silently drops later ones, so the two
* concerns stop fighting.
*
* WHY IT IS A CORRECTNESS FIX, NOT COSMETICS: VerticalTabsLayout keys its tab-element map by row key
* (`registerTab(row.key, el)`) and computes highlight as `row.key === active`. Two rows sharing an id
* means the second node overwrites the first in that map (so roving-focus can only ever reach one),
* BOTH copies render as active simultaneously, and `orderedKeys` visits the duplicate twice — arrow
* keys appear to stall. De-duplicating by id restores all three invariants at once.
*
* Generic by construction: nothing here names a bundle, so it holds for every plugin added later.
*/
function dedupeRows(rows, seen) {
const out = []
for (const r of rows) {
if (!r || seen.has(r.key)) continue // a later section yields to the first declaration
seen.add(r.key)
out.push(r)
}
return out
}
/**
* THE SPEC'S REQUIRED GATE — "any implementation must assert that no tab key appears twice across
* all sections" (SPEC-the-six-map-product-and-its-tab-order.md, "the duplicate-key trap"). dedupeRows
* above is the RUNTIME safety net (silently keeps the first declaration, so a duplicate can never
* stall the rail); this is the LOUD check that a duplicate should not exist among the CURATED
* sections in the first place — the previous regression happened because nobody had this and a
* silent safety net alone does not stop a duplicate from being written again by a future edit.
*
* Runs once at module load (see the call below NAV_MODEL/TAB_DEFS), so a duplicate is a build/dev-
* console error the moment the file is edited wrong, not something only Playwright would catch.
* Never throws in production: a duplicate is a code defect to fix, not a reason to take the whole
* SPA down for a live user, so this reports loudly and returns rather than throwing.
*/
function assertNoDuplicateTabKeys(navModel) {
const seen = new Map() // id -> section key that first claimed it
const dupes = []
for (const g of navModel) {
if (g.key === 'experimental') continue // the catch-all is generic by construction; nothing to assert
for (const r of g.tabs || []) {
if (!r || !r.id) continue
const first = seen.get(r.id)
if (first === undefined) { seen.set(r.id, g.key); continue }
if (first !== g.key || dupes.indexOf(r.id) === -1) dupes.push(r.id + ' (' + first + ' + ' + g.key + ')')
}
}
if (dupes.length && typeof console !== 'undefined') {
console.error('[doxa-maps-spa] duplicate tab key(s) across NAV_MODEL sections: ' + dupes.join(', ') +
' — VerticalTabsLayout keys its tab-ref map and `active` highlight by id, so a row declared in ' +
'two sections paints twice and stalls keyboard nav. See the duplicate-key trap in ' +
'SPEC-the-six-map-product-and-its-tab-order.md.')
}
return dupes
}
// ★ FRESH BUNDLES AFTER A DEPLOY, AT THE SAME URLs (2026-10-02). A host that serves the tree with Last-Modified and no
// Cache-Control (the DX Life landing does) lets a browser run its cached copies for hours: measured, a typed-URL revisit
// after a deploy requested NONE of the gallery, the manifest or the bundles, so a returning reader ran the old files, or
// an old manifest against a new bundle. A version query cannot fix it here: loadScriptOnce keys its registry by the full
// href, so X.js?v= beside a composite's plain X.js would run the bundle twice. So the manifest is always revalidated, and
// when its fingerprint (every bundle's integrity) differs from this reader's last visit to this tree, every listed bundle
// gets ONE conditional request before any tab mounts: a 304 refreshes the cached copy, and a 200 is cancelled at its
// headers, which drops the stale entry. The plain <script> that follows (ours, or a composite's for its children) then
// runs the new file. An ordinary visit costs nothing extra.
const TREE_FP_KEY = 'dxm.treeFingerprint@'
function treeFingerprint(manifest) {
const bundles = (manifest && Array.isArray(manifest.bundles)) ? manifest.bundles : []
const s = bundles.map(b => (b && b.bundle) + ':' + ((b && b.integrity) || '')).join('|')
let h = 2166136261 // FNV-1a: a short, stable key, not a security hash
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
return (h >>> 0).toString(36) + '.' + s.length
}
function readTreeFp(root) { try { return localStorage.getItem(TREE_FP_KEY + root) } catch { return null } }
function writeTreeFp(root, fp) { try { localStorage.setItem(TREE_FP_KEY + root, fp) } catch { /* private mode: revalidate next time */ } }
// Turn a bundle id into a human label: "all-people-groups" → "All People Groups". Small, local and
// dependency-free; a bundle may override it by shipping a profile `title` in the manifest.
function titleize(id) {
return String(id).split(/[-_]/).filter(Boolean)
.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
}
/**
* GENERIC EXPERIMENTAL DISCOVERY — the whole point of Card 2.
*
* Reads the BUILT manifest.json (the authoritative list of what actually exists in this tree — the
* same file the gallery and every bundle page read) and returns a tab def for every bundle that is
* (a) not already placed in a curated section and (b) not in NOT_EXPERIMENTAL. Nothing about any
* specific plugin is encoded here, so a brand-new bundle shows up on the next build by itself.
*
* WHY THE MANIFEST AND NOT A DIRECTORY LISTING: manifest.json is generated by the build and already
* honors `gatedProfiles` (e.g. key-cities is intentionally withheld), so reading it inherits every
* publishing decision for free. A raw listing of the served folder would resurrect gated/stale
* directories as phantom tabs.
*
* WHY RUNTIME AND NOT BUILD-TIME: baking the list in at compile time would just move the hardcoding
* one layer down — the shell would still ship a frozen list. Fetching at runtime means the SAME
* built shell adapts to whatever tree it is dropped into. manifest.json is inside the drop-in
* allowlist, so it travels with the tree everywhere the shell does.
*
* FAIL-SOFT: if the manifest is unreachable (offline file:// drop, a host that did not ship it), we
* return [] and the Experimental folder simply stays empty. The curated sections are unaffected —
* a discovery failure must never take down the app's main rail.
*/
function experimentalTabsFromManifest(manifest) {
const placed = curatedBundles()
const bundles = (manifest && Array.isArray(manifest.bundles)) ? manifest.bundles : []
const out = []
for (const b of bundles) {
const id = b && b.bundle
if (!id || placed.has(id) || NOT_EXPERIMENTAL.has(id) || b.stage === 'production') continue // stage: the bundle's .meta.json, same field the Plugin Studio reads
// Pick the bundle's entry profile: prefer a real MAP profile, else the profile named like the
// bundle, else simply the first one. A bundle contributes exactly ONE rail row (see dedupeRows),
// so this chooses which of its profiles that row opens on.
const profiles = Array.isArray(b.profiles) ? b.profiles : []
if (!profiles.length) continue
// Not for the web: a plugin whose every profile is a corner widget or declares targets without "web"
// (Rudder: desktop + the Plugin Studio's corner). As a tab it probed a backend that a static host
// does not have, and every embed of the gallery logged three 404s.
if (profiles.every(p => p.widget || (Array.isArray(p.targets) && !p.targets.includes('web')))) continue
const entry = profiles.find(p => p.isMap) || profiles.find(p => p.profile === id) || profiles[0]
// A discovered tab nests the same way a curated one does: its ELEMENT, loaded from the bundle's
// own scriptUrl. Both are read straight off the manifest, so this path needs no knowledge of the
// tree layout — which is the whole reason discovery survives a move between the dev and built
// trees. (It previously took entry.iframeUrl and framed the leaf page.)
const tag = entry.element || (Array.isArray(b.elements) && b.elements[0]) || id
const scriptUrl = b.scriptUrl || entry.scriptUrl || ''
if (!scriptUrl) continue
out.push({
id, // bundle id doubles as the stable tab id
label: entry.title || titleize(id),
sub: (entry.description || '').trim().replace(/[,;]\s*$/, '') || 'experimental bundle',
emoji: '🧪',
tag,
bundle: id,
profile: entry.profile || id,
scriptUrl,
heavy: HEAVY_ELEMENTS.has(tag) || HEAVY_ELEMENTS.has(id),
scroll: SCROLL_ELEMENTS.has(tag) || SCROLL_ELEMENTS.has(id),
discovered: true // marks an auto-discovered tab (used by the rail + tests)
})
}
// Ranked first (Kingdom impact, see EXPERIMENTAL_RANK), then A-Z -- stable between builds.
out.sort((a, z) => {
const ra = EXPERIMENTAL_RANK[a.id] || 999
const rz = EXPERIMENTAL_RANK[z.id] || 999
return ra !== rz ? ra - rz : a.label.localeCompare(z.label)
})
return out
}
/**
* CURATED TAB → its real element, resolved from the manifest.
*
* Each TAB_DEF names a `bundle` + `profile`; this looks both up in manifest.json and fills in the two
* things only the manifest knows: the bundle's `scriptUrl` and the profile's element `tag`. Returns a
* patch per tab id, so a tab whose bundle is missing from this tree is simply left as declared and
* pruned by the caller (never painted as a dead row).
*
* WHY LOOK UP THE TAG TOO, rather than keep it in TAB_DEFS: the tag was already hardcoded there and
* was already WRONG in one place — `prayer-map` declared tag 'doxa-simple-map' (the BUNDLE name),
* while that bundle's element is actually <doxa-map>. Under the old iframe mode the tag was never
* used to create anything, so the error was invisible. Mounting directly would have created an inert
* <doxa-simple-map> and rendered an empty pane. One source of truth removes the whole class.
*
* INSTANCE FALLBACK: a tab may name an optional `instance` (a preset of a parameterized bundle, e.g.
* doxa-simple-map's `prayer`). Where the manifest lists it, it is passed down as profile-config
* instanceId; where it does not exist (the dev tree has no `prayer`), the tab still mounts the
* bundle's profile rather than failing. A curated tab must degrade, not disappear.
*/
function resolveTabsFromManifest(manifest, defs) {
const bundles = (manifest && Array.isArray(manifest.bundles)) ? manifest.bundles : []
const byBundle = Object.fromEntries(bundles.map(b => [b.bundle, b]))
const out = {}
for (const d of defs) {
const b = byBundle[d.bundle]
if (!b) continue // not in this tree → caller prunes the row
const profiles = Array.isArray(b.profiles) ? b.profiles : []
const p = profiles.find(x => x.profile === d.profile) || profiles[0]
const tag = (p && p.element) || (Array.isArray(b.elements) && b.elements[0]) || d.bundle
const scriptUrl = b.scriptUrl || (p && p.scriptUrl) || ''
if (!scriptUrl) continue
// Only claim the instance if this tree actually ships it.
const instances = (p && Array.isArray(p.instances)) ? p.instances : []
const hasInstance = !!(d.instance && instances.some(i => (i && (i.id || i.instance)) === d.instance))
out[d.id] = {
tag,
scriptUrl,
profile: (p && p.profile) || d.profile,
instance: hasInstance ? d.instance : '',
heavy: HEAVY_ELEMENTS.has(tag) || HEAVY_ELEMENTS.has(d.bundle),
scroll: SCROLL_ELEMENTS.has(tag) || SCROLL_ELEMENTS.has(d.bundle)
}
}
return out
}
export default defineComponent({
name: 'DoxaMapsSpa',
// THE HOST'S ATTRIBUTES STAY ON THE HOST. This component is the custom-element root, so Vue would
// otherwise copy every non-prop attribute of <doxa-maps-spa> — a host's style="…", class, data-* —
// onto VerticalTabsLayout's .vtl. An inline display there beats the layout's own rules; that is
// exactly how the Prayer Walk Planner's map collapsed to 76px when this gallery styled its host
// (measured 2026-09-25). .vtl survives today only because it declares display:grid !important.
// Everything the layout needs (dir, lang, labels, …) is passed explicitly below.
inheritAttrs: false,
components: { VerticalTabsLayout, BackToTopButton, MapInfo, ShellLanguageMenu },
// profile-config props the standalone leaf page + gallery hand every element. treeBase lets a
// host pin the tree root explicitly; the rest are accepted so the element is a well-behaved
// member of the bundler (profile/instanceId/assetsBase/tk/shareUrl/qa are ignored here — the
// app-shell itself paints no map, its tabs do).
props: {
treeBase: { type: String, default: '' },
profileConfig: { type: [String, Object], default: '' },
// NOT `title` — see the matching note in plugin-studio.vue. `title` is a native HTMLElement
// property, and defineCustomElement maps every prop to an attribute, so Vue warns on every
// mount that it will overwrite the native one. Both capability-5 shells had the same
// declaration, which is the same duplicate-implementation pattern that turned one resolver
// assumption into two separate outages — when one shell is fixed, grep for its twin.
// DX Life publishes this gallery as "Deep Research" (a hidden page on its site), so that is the
// default name. A host may still pass its own, as this prop or as profile-config.heading.
heading: { type: String, default: 'Deep Research' },
// Optional: pin the Mapbox token / token URL forwarded to each tab's element via profile-config.
tokenUrl: { type: String, default: '' },
tk: { type: String, default: '' },
// LOCALIZATION PROP (loc architecture). The SPA has NO standalone language dropdown by default;
// it INHERITS its language from the parent page via this `language` prop (canonical name). When a
// cap-5 header hosts a dropdown it sets this prop for all tabs; otherwise the host page passes it
// down. `locale`/`lang` are also accepted via profile-config (ProfileLoader's contract) — see
// 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: '' },
// ── THE EMBED CONTRACT (card DELEGATE-NEW-TAB-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: '' },
// The map engine for every nested map: 'maplibre' (keyless) or 'mapbox'. Also accepted as
// profile-config.provider. Unset = each map's own ladder (?provider= → window.MAP_PROVIDER → mapbox).
provider: { 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: '' },
// The HOST's homepage: a "Back to homepage" link directly above the rail's Collapse button (a phone
// shows it at the top of the drawer). Also accepted as profile-config.homeHref / homeLabel. With no URL
// from the host, the link goes to the DOXA site in the gallery's language (defaultHomeHref).
homeHref: { type: String, default: '' },
homeLabel: { 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).
const injectedBase = inject('dmsTreeBase', '')
return { injectedBase }
},
data() {
return {
// The .dms-pane elements, one per tab, keyed by index. The SPA's pane IS the scroller
// (overflow-y:auto), so the back-to-top has to follow activeIndex rather than bind one ref.
// Not reactive-deep on purpose: these are DOM nodes.
paneEls: {},
activeIndex: 0,
// The cap-5 DX-Maps SPA is HEADER-OFF by default (sidebar-only, full-page — no stacked headers
// when embedded under a host that already has one). This ONE flag drives BOTH the shell header
// and the standalone locale dropdown, so the rule (header-off → no dropdown; the SPA
// inherits its language from the parent via the `language` prop) can never drift: see
// showLocaleSwitcher. A future host that wants the SPA's own chrome flips this to true.
showHeader: false,
// ★ VIEWPORT WIDTH AS REACTIVE STATE, for the device rule (default row + Big Picture disable).
// 760px is THE SAME BREAKPOINT library/layouts/VerticalTabsLayout.vue uses for its rail/drawer
// switch. The layout keeps its own `narrow` flag PRIVATE (it emits only update:active), so the
// shell cannot read it and must watch the same query itself. That duplicates a CONSTANT, not
// logic — but if either side ever moves, move BOTH: a shell that thinks it is on desktop while
// the rail has become a drawer would leave Big Picture enabled inside a mobile drawer, which is
// precisely what the device rule forbids.
narrow: false, _mql: null, _onMedia: null,
// The transient "desktop only" message shown when Big Picture is clicked while disabled (see
// onSelect's intercept). '' renders nothing; _noticeTimer clears any earlier message's timeout
// so two quick clicks don't fight over when it disappears.
disabledNotice: '', _noticeTimer: null,
helpOpen: false,
mobileNoticeDismissed: {},
// Phone flags from the manifest (mobileFlagsFromManifest); null until it lands. See select().
mobileFlags: null,
// Set when EVERY manifest candidate failed. The manifest carries each tab's scriptUrl, so
// without it no src-composed tab can mount; this turns that into a visible pane message
// instead of an empty grey rectangle. See loadManifest().
manifestError: '',
// The DIRECTORY of the manifest that actually answered, with a trailing slash. Tree-relative
// 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(),
// The host page's own language (detectHostLocale), kept live by a MutationObserver on <html lang>
// — a host app that switches language at runtime (Nuxt i18n does) re-drives the whole gallery.
hostLang: detectHostLocale(),
// A language picked in the shell's menu in THIS session that no host took (see pickLocale). It
// outranks the `language` prop it was picked over, and a change to that prop clears it. A saved
// choice is never a source: loadLocale falls back to the browser's language, which would
// override a partner page's own <html lang>.
sessionPick: '',
localeOpen: false,
switcherLangs: SWITCHER_LANGUAGES,
// Curated tabs start from TAB_DEFS and are COMPLETED from the manifest (tag + scriptUrl) as
// soon as it arrives — see applyManifest. Until then `scriptUrl` is empty and select() simply
// defers the mount, so a slow manifest delays the first paint instead of erroring.
tabs: TAB_DEFS.map(t => ({
...t,
tag: '', // resolved from the manifest (never hardcoded — see resolveTabsFromManifest)
scriptUrl: '', // resolved from the manifest; correct in BOTH the dev and built trees
heavy: false, // resolved from the manifest's tag (a tag name alone cannot tell you)
scroll: false, // resolved from the manifest's tag — true only for the two stack composites
resolved: false, // has the manifest filled this tab in yet
mounted: false, // has the tag been created yet
state: 'idle', // idle | loading | ready | error
error: ''
}))
}
},
computed: {
// The pane notice for a larger-screens map: the library's sentence, so the Gallery and the map's
// own leaf page say the same thing (SPEC-MOBILE-FLAG).
largerScreenText() {
return tStatic('messages.largerScreenNotice', this.uiLocale)
|| 'This experimental map is built for larger screens. It opens here, but parts of it may not fit or work well on a phone.'
},
// ★ THE SCROLLER IS THIS PANE, MEASURED, NOT VTL'S. At 390x844 on the live build:
// section.dms-pane has clientHeight 712 and scrollHeight 12,373 with overflow-y:auto, while
// the document does not scroll at all and there is NO .vtl-host element in the tree.
// VerticalTabsLayout does render a BackToTopButton, but it binds the active .vtl-host, which
// only exists for rows carrying an `element` — and this SPA passes :groups whose rows carry
// none, mounting its own panes instead. So that button's target is null here and it renders
// nothing: the layout's button is for layouts that host their own element rows, not for us.
activePaneEl() { return this.paneEls[this.activeIndex] || null },
// The active tab's "?" text, or null: only discovered (Experimental) tabs, and only when
// experimentalHelp.js has an entry for that bundle in this locale (English fallback inside).
activeHelp() {
const t = this.tabs[this.activeIndex]
return t && t.discovered ? helpFor(t.id, this.uiLocale) : null
},
// Merge profile-config (string or object) over the discrete props, so a host can pass either.
cfg() {
let pc = this.profileConfig
if (typeof pc === 'string') { try { pc = pc ? JSON.parse(pc) : {} } catch { pc = {} } }
return pc && typeof pc === 'object' ? pc : {}
},
// The tree root URL, with a trailing slash. Precedence:
// 1. treeBase prop / profile-config.treeBase (host override)
// 2. the base index.js derived from the running <script> URL
// 3. a same-tree relative guess ('') so a co-located drop still resolves.
base() {
const raw = (this.treeBase || this.cfg.treeBase || this.injectedBase || '').trim()
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
// 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
// presentation, the mount path is unchanged. Rows whose tab id has no matching TAB_DEF are
// dropped, and a group with no surviving rows is omitted (never paint a dead section/row).
//
// ★ THE DEVICE RULE, PER THE SPEC: the tab order never changes; on mobile the 2nd tab opens by
// default and the 1st is disabled. That is why there is NO width
// filter on rows here any more (the old `onlyWhen:'desktop'|'mobile'` hide-a-row mechanism is
// gone — every row is in the rail on every width, same order, always). The ONLY width-driven
// thing left is which row is DISABLED (Big Picture, on mobile — see isDesktopOnly) and
// which row auto-opens (see firstVisibleIndex) — both presentation, neither touches NAV_MODEL.
showInfo() { const v = this.cfg.info; return !(v === false || v === 'false' || v === 'off') },
headingText() { return String(this.cfg.heading || this.heading || '').trim() || 'Deep Research' },
// profile-config `experimental: false` (or "hidden") hides the Experimental folder: no discovery, no
// rows, and a tab= request for an experimental map falls back to the first visible tab. OFF only on
// request, so every other host keeps the folder exactly as before.
showExperimental() {
const v = this.cfg.experimental
return !(v === false || v === 'false' || v === 'hidden' || v === 'off')
},
navGroups() {
const byId = Object.fromEntries(this.tabs.map(t => [t.id, t]))
// ONE `seen` set threaded across ALL groups (not per-group): the rail must hold one node per tab
// id GLOBALLY, since the layout's tab-ref map and `active` highlight are keyed by id across the
// whole rail. A per-group set would still let two sections each paint the same tab. See
// dedupeRows() for the rule and why duplicates are a correctness bug, not a cosmetic one.
const seen = new Set()
return NAV_MODEL
.map(g => {
let rows = dedupeRows(
(g.tabs || [])
.filter(r => byId[r.id])
.map(r => {
const t = byId[r.id]
// Big Picture is DISABLED (not hidden) on mobile — the one row-level exception the
// device rule requires. `disabled`/`badge` are read by THIS shell's onSelect intercept
// and CSS below; VerticalTabsLayout itself does not know about `disabled` (it has no
// such concept — see the comment on isDesktopOnly), so the row stays clickable
// at the layout level and this shell is what turns that click into a message instead
// of a mount.
const disabled = this.isDesktopOnly(t.id)
// A desktop-only map says so on EVERY width, not only once a phone has disabled it:
// a desktop reader should know before sharing the link to a phone.
const desktopOnly = disabled || this.mobileFlagOf(t.id) === 'desktop-only'
return {
key: t.id, label: this.tabLabel(t), sub: this.tabSub(t), icon: r.icon,
badge: desktopOnly ? this.tr('desktopOnly') : r.badge, disabled
}
}),
seen)
// The Experimental folder is not authored — it is FILLED with every discovered tab (any
// built bundle no curated section placed). This is the one group whose rows come from the
// manifest rather than NAV_MODEL, which is what makes a new plugin appear by itself.
if (g.key === 'experimental' && !this.showExperimental) return { key: g.key, rows: [] }
if (g.key === 'experimental') {
// Discovered rows go through the SAME de-dup gate as curated ones. Belt-and-braces: a
// discovered bundle should already be excluded by curatedBundles()/NOT_EXPERIMENTAL, but
// routing it through `seen` means even a mis-declared bundle can only ever paint one row.
rows = rows.concat(dedupeRows(
this.tabs.filter(t => t.discovered)
.map(t => ({ key: t.id, label: this.tabLabel(t), sub: this.tabSub(t), icon: 'flask',
badge: this.narrow && this.mobileFlagOf(t.id) === 'larger-screens' ? this.tr('mobileNotBuilt') : this.tr('lab') })),
seen))
}
return { key: g.key, label: g.i18n ? this.tr(g.i18n) : g.label, icon: g.icon, folder: !!g.folder, open: g.open, rows }
})
.filter(g => g.rows.length)
},
// Whether the standalone #toolbar locale dropdown renders. The rule: the cap-5 SPA has
// NO standalone dropdown by default — it INHERITS its language from the parent via the `language`
// prop (effectiveLocale). The dropdown is only offered when the header is ON (a host opting the
// SPA into its own chrome). Keyed to the SAME flag as the shell header so the two never diverge.
showLocaleSwitcher() { return this.showHeader },
// The active language's flag/native name for the toolbar toggle button.
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } },
// The language the HOST says: the `language` prop, or profile-config.language|locale|lang.
propLocale() {
return (this.language || this.cfg.language || this.cfg.locale || this.cfg.lang || '').trim()
},
// The EFFECTIVE preview language pushed down to every tab (each tab's element receives it via
// profile-config.locale). Precedence, most-authoritative first:
// 1. a pick from the shell's language menu in THIS session that no host took (sessionPick), which
// the watcher below clears as soon as the host's `language` changes;
// 2. `language` prop / profile-config.language|locale|lang: what the host SAYS;
// 3. the header-on switcher's choice, only while that switcher is actually offered;
// 4. the host page's language (?lang= / <html lang>, see detectHostLocale);
// 5. English.
effectiveLocale() {
const fromSwitcher = this.showLocaleSwitcher ? this.locale : ''
return (this.sessionPick || this.propLocale || fromSwitcher || this.hostLang || 'en').split('-')[0].toLowerCase()
},
// The locale the gallery's OWN words render in: the effective one when it ships, else English.
uiLocale() { return LOCALES.includes(this.effectiveLocale) ? this.effectiveLocale : 'en' },
// "Back to homepage" with no host to name one (a standalone page, the published tree): the DOXA site, in the
// gallery's language. A host's home-href, or profile-config.homeHref, always wins.
defaultHomeHref() { return 'https://doxa.life' + (this.uiLocale && this.uiLocale !== 'en' ? '/' + this.uiLocale : '') },
// Direction FOLLOWS THE GALLERY'S LANGUAGE, set explicitly on the layout root (a fallthrough attr
// on VerticalTabsLayout's .vtl): Arabic lays the shell out right-to-left (the layout mirrors its
// rail under :dir(rtl)), every other shipped locale left-to-right — including an English gallery
// placed on some other RTL page, whose Latin rail must not be mirrored.
uiDir() { return this.uiLocale === 'ar' ? 'rtl' : 'ltr' },
// The shared layout's own controls (its visible "Collapse" button and four aria-labels) — English
// defaults inside VerticalTabsLayout, translated here through its `labels` contract.
layoutLabels() {
const out = {}
for (const k of ['toggleList', 'openViews', 'expandList', 'collapseList', 'collapse', 'home']) out[k] = this.tr(k)
return out
},
// The engine a HOST asked for ('' = none; each map keeps its own ladder).
effectiveProvider() {
const p = String(this.provider || this.cfg.provider || '').trim().toLowerCase()
return (p === 'maplibre' || p === 'mapbox') ? p : ''
},
activeKey() {
const t = this.tabs[this.activeIndex]
return t ? t.id : ''
}
},
mounted() {
// ★ Establish the viewport class BEFORE select(0), so the first tab opened is the one that will
// actually be in the rail. Setting it after would open Big Picture on a phone for one frame and
// then drop the row out from under the active highlight — the tab would be mounted but
// unreachable, which reads as a broken app rather than a flicker.
this.watchWidth()
this.watchHostLang()
// 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.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
// re-drives it, so the first tab mounts the moment the manifest lands.
this.loadManifest()
// A map's own "?" asks for its help with a composed doxa:help event (library HelpButton).
this._onHostedHelp = (e) => this.onHostedHelp(e)
this.$el.addEventListener('doxa:help', this._onHostedHelp)
// Back / Forward re-open the tab the address names (useViewAddress), and never write. Only where a page OWNS
// its address: on a partner's page a popstate is the partner's own navigation, and re-reading the URL there
// would snap a host's fixed `tab` attribute back to the default. The owner is read per event: a host may set
// profile-config after mount.
this._offAddress = onAddressChange(({ tab }) => { if (urlOwnerOf(this.cfg)) this.openFromAddress(tab) })
// A nested composite's choice (its open pane, a selected place) reaches the address through this shell, the
// page's one writer, while the page owns its address; a nested element can never switch this shell's tab.
this._offOwn = ownAddress(this.$el, () => urlOwnerOf(this.cfg))
},
// Release the media-query listener. This element is a custom element a host can add and REMOVE at
// will (a cap-5 SPA is mounted and unmounted by shells, tabs and the studio), and a live listener
// holding a reference to a detached component leaks it for the page's lifetime.
beforeUnmount() {
if (this._langObs) { this._langObs.disconnect(); this._langObs = null }
if (this._onHostedHelp) { this.$el.removeEventListener('doxa:help', this._onHostedHelp); this._onHostedHelp = null }
if (this._offAddress) { this._offAddress(); this._offAddress = null }
if (this._offOwn) { this._offOwn(); this._offOwn = null }
if (!this._mql || !this._onMedia) return
if (this._mql.removeEventListener) this._mql.removeEventListener('change', this._onMedia)
else if (this._mql.removeListener) this._mql.removeListener(this._onMedia)
this._mql = null; this._onMedia = null
},
watch: {
activeIndex() { this.helpOpen = false },
// The host changed its language: that is the newest word, so it replaces a menu pick made earlier.
propLocale() { this.sessionPick = '' },
// ★ A LANGUAGE SWITCH REMOUNTS, IT DOES NOT RE-SET THE ATTRIBUTE. Re-setting the visible tab's
// profile-config was the old path, and it relied on every nested map re-reading its whole config
// live. Two production maps do not: Big Picture's tStatic strings are resolved once (2/352 changed,
// measured), and Simple Research's composite copies its config into its panes once (0/43, 0/105).
// A fresh element reads effectiveLocale at mount, so the visible tab is rebuilt in the new language
// whatever each map does internally, the same way a provider change rebuilds. View state (filters,
// zoom) is lost on a language switch, which is acceptable.
// Hidden tabs that are still MOUNTED (a non-heavy tab stays mounted when hidden, and select() does
// not remount a mounted tab) are released, so their next open mounts fresh in the new language.
effectiveLocale() {
// Reset IN PLACE, not with releaseTab: releaseTab deletes the slot ref, and a light (v-show) pane
// is rendered once, so its slot ref would never be set again and the next mount would find none.
// Emptying the slot runs the old element's disconnected lifecycle, which frees its map.
const reset = (i, t) => {
try { const slot = this._slots && this._slots[i]; if (slot) slot.innerHTML = '' } catch { /* reset anyway */ }
t.mounted = false; t.state = 'idle'; t.error = ''
}
this.tabs.forEach((t, i) => { if (t.mounted && i !== this.activeIndex) reset(i, t) })
const i = this.activeIndex
const t = this.tabs[i]
if (!t || !t.mounted) return
reset(i, t)
this.mountElement(i)
},
// 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: {
openHelp() { this.helpOpen = true; this.$nextTick(() => this.$refs.helpClose && this.$refs.helpClose.focus()) },
closeHelp() {
this.helpOpen = false
const back = this._helpReturn; this._helpReturn = null
this.$nextTick(() => back && back.focus && back.focus())
},
// Does this tab have what/how text? Marks its pane, so a map's hosted "?" knows to show.
// A tab's button shows only when its lead text is in the bundled catalogue: a key that has not
// landed would otherwise open a popup of raw key names. English is the check because every
// locale falls back to it.
// The entry minus `pos`, which is placement for the button, not a MapInfo prop.
infoProps(t) { const { pos, ...props } = this.infoFor(t) || {}; return props },
// pos -> MapInfo's own offset variables, inherited by its button and pointer.
// The tab's placement at this width: pos, with pos.narrow laid over it on a phone.
infoPos(t) {
const pos = (this.infoFor(t) || {}).pos || {}
const { narrow, ...wide } = pos
return this.narrow && narrow ? { ...wide, ...narrow } : wide
},
infoStyle(t) {
const pos = this.infoPos(t)
const s = {}
if (pos.top) s['--map-info-top'] = pos.top
if (pos.side) s['--map-info-side'] = pos.side
return s
},
infoFor(t) {
const e = this.showInfo && t && TAB_INFO[t.id]
return e && (!e.leadKey || tStatic(e.leadKey, 'en')) ? e : null
},
tabHasHelp(t) { return !!(t && t.discovered && helpFor(t.id, this.uiLocale)) },
// A map's "?" (library HelpButton) asked for help. Answer only for the active tab, and only when
// it has text; remember the exact button (composedPath reaches into the map's shadow root) so
// closing returns focus to it.
onHostedHelp(e) {
if (!this.activeHelp) return
e.stopPropagation()
const path = e.composedPath ? e.composedPath() : []
this._helpReturn = path.find((n) => n && n.tagName === 'BUTTON') || null
this.openHelp()
},
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
// The gallery's own words in uiLocale (src/strings.js). `t` in the template is shadowed by the
// v-for tab variable inside the panes, so those use `tr`; both are the same function.
tr(key, params) { return translate(this.uiLocale, key, params) },
t(key, params) { return translate(this.uiLocale, key, params) },
// A curated tab's label/sub come from the string table; a DISCOVERED (Experimental) tab has only
// its manifest title, which stays as published. Its English manifest description is replaced by
// the translated generic subtitle outside English, so no English prose leaks into the rail.
// Known Experimental bundles have proper names in the table (the manifest's titleized id reads
// "Upg Neighbor" even in English); an unknown future bundle keeps its manifest title.
tabLabel(t) { return (!t.discovered || hasKey('tab.' + t.id)) ? translate(this.uiLocale, 'tab.' + t.id) : t.label },
tabSub(t) {
if (!t.discovered) return translate(this.uiLocale, 'sub.' + t.id)
return this.uiLocale === 'en' ? t.sub : this.tr('experimentalSub')
},
// ── VIEWPORT WATCH (the device rule: same rail, different default + one disabled row) ────────
// Mirrors the SAME (max-width:760px) query VerticalTabsLayout uses. See the `narrow` note in
// data() for why the shell must watch it independently.
//
// FAIL-SOFT: if matchMedia is unavailable (an old webview, a non-browser render) `narrow` stays
// false and the SPA behaves exactly as it did before this change — desktop rail, Big Picture
// first. A viewport feature must never be able to take the app down.
// Follow the host page's <html lang> live. Fail-soft: without MutationObserver the language read
// at mount stands.
watchHostLang() {
if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return
this._langObs = new MutationObserver(() => { this.hostLang = detectHostLocale() })
this._langObs.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
},
watchWidth() {
if (typeof window === 'undefined' || !window.matchMedia) return
this._mql = window.matchMedia('(max-width: 760px)')
this.narrow = this._mql.matches
this._onMedia = (e) => {
const wasNarrow = this.narrow
this.narrow = !!e.matches
if (wasNarrow === this.narrow) return
// A breakpoint crossing can DISABLE the row that is currently active (Big Picture, going
// desktop→mobile). Every row stays IN the rail on both widths per the device rule — nothing
// is removed any more — but a disabled active tab must not be left mounted/highlighted with
// no way to reach it, so snap to the default row for the new width.
if (this.isDesktopOnly(this.activeKey)) this.select(this.firstVisibleIndex())
}
// addEventListener is the modern API; addListener is the deprecated fallback some webviews
// still need. Try both rather than assume — a missed listener means the swap silently never
// happens on rotate.
if (this._mql.addEventListener) this._mql.addEventListener('change', this._onMedia)
else if (this._mql.addListener) this._mql.addListener(this._onMedia)
},
// The phone flag of a tab's own bundle/profile, as the manifest states it (null until it lands).
mobileFlagOf(id) {
const t = this.tabs.find(x => x.id === id)
return (t && this.mobileFlags) ? (this.mobileFlags[t.bundle + '/' + t.profile] || null) : null
},
// THE ROWS THE DEVICE RULE DISABLES. The tab order does not change on a phone: the default opens
// the next usable row and the disabled one stays in place. Named as a predicate (not a width filter
// in navGroups) so navGroups, onSelect and watchWidth all ask the same question.
isDesktopOnly(id) {
return this.narrow && this.mobileFlagOf(id) === 'desktop-only'
},
// A map that opens on a phone but warns first, until dismissed.
showsMobileNotice(id) {
return this.narrow && this.mobileFlagOf(id) === 'larger-screens' && !this.mobileNoticeDismissed[id]
},
dismissMobileNotice(id) {
this.mobileNoticeDismissed = { ...this.mobileNoticeDismissed, [id]: true }
},
// Index (into this.tabs) of the row the rail should auto-open at the CURRENT width. Desktop opens
// tab 1 (Big Picture); mobile opens tab 2 (Simple Research) because tab 1 is disabled there — the
// rail itself is UNCHANGED, only the default selection moves. Falls back to the first row of the
// first surviving group (then 0) so the app always opens something even if a future NAV_MODEL
// edit leaves a width with no curated rows.
firstVisibleIndex() {
let fallback = -1
for (const g of this.navGroups) {
for (const r of g.rows) {
const i = this.tabs.findIndex(t => t.id === r.key)
if (i < 0) continue
if (fallback < 0) fallback = i
if (!r.disabled) return i
}
}
return fallback >= 0 ? fallback : 0
},
// Fetch the manifest ONCE and use it for BOTH jobs: complete the curated tabs (tag + scriptUrl)
// and fill the Experimental folder. One read, because the two answers must describe the same
// tree — fetching twice would let a curated tab and a discovered tab disagree about what exists.
//
// Fail-soft by design: any error (no manifest shipped, a file:// drop where fetch is blocked,
// malformed JSON) leaves the curated tabs unresolved and Experimental empty rather than throwing.
//
// ★ FAIL-SOFT IS NOT FAIL-SILENT (the Simple Research grey pane, seen four times).
// Every `src`-composed tab — Simple Research, Regions, every map — gets its `scriptUrl` from
// this manifest and from nowhere else. Only tab 1 (Big Picture) is BAKED, so only tab 1 can
// paint without it. When this fetch 404'd the old code swallowed it, mountTab() hit
// `if (!t.scriptUrl) return`, and the pane stayed in state 'idle' — which renders NEITHER the
// loading hint NOR the error hint. The result was a perfectly empty grey pane with no console
// error, no overlay, and a correctly-highlighted rail row: it looks exactly like a mount
// failure and is actually a missing file. MEASURED under `bun run dev` at
// /plugin-designs/doxa-maps-spa/index.html: 404 /plugin-designs/manifest.json, deep element
// count 134, <research-map-plus-grid> count 0. The SAME shell on the BUILT tree resolved the
// manifest and painted 4778px of content with a live canvas.
//
// TWO CANDIDATES, because the shell runs in two trees that put the manifest in different
// places. The built tree has it beside the bundle folders (resolve() → <base>/manifest.json).
// The dev tree serves SOURCE from /plugin-designs/, where no manifest exists — it is a BUILD
// ARTIFACT and lives under /public/<tree>/ (the build config names the tree). Trying the built-tree path second means a dev
// run finds the real manifest instead of going dark, and a production run is unchanged
// (candidate 1 answers, candidate 2 is never requested).
loadManifest() {
// Each candidate is { url, root }: WHERE to read the manifest, and the tree ROOT its
// tree-relative scriptUrls resolve against (normally the manifest's own directory).
const rootUrl = this.resolve('manifest.json')
const dirOf = (u) => { try { return new URL(u, location.href).href.replace(/[^/]*$/, '') } catch { return '' } }
const root = { url: rootUrl, root: dirOf(rootUrl) }
// ★ THE CORS-READABLE COPY (generate-tree writes it beside this bundle, byte-identical).
// A script embed on a partner page FETCHES the manifest cross-origin, and the live static layer
// sends Access-Control-Allow-Origin only on paths INSIDE a folder of the tree — every bundle file
// and assets/ carry it, the ROOT manifest.json does not (measured 2026-09-25 on the live test host: the
// root 200 without it, doxa-maps-spa/<anything> with it, even a 404). So on a foreign page the
// copy is read FIRST (no CORS error in the host's console) and the root second; same-origin keeps
// the root first. Script URLs still resolve against the TREE ROOT, never the copy's folder.
const copy = { url: this.resolve('doxa-maps-spa/manifest.json'), root: root.root }
let crossOrigin = false
try { crossOrigin = new URL(rootUrl, location.href).origin !== location.origin } catch { /* relative = same */ }
const candidates = crossOrigin ? [copy, root] : [root, copy]
// The tree's name comes from the build config (vite define), never a literal here: this was the
// one tree path compiled into any bundle, so a renamed tree would have needed a gallery edit.
// ★ THE DEV SERVER SERVES public/ AT THE ROOT. Reached as /public/<tree>/…, Vite treats a built bundle as SOURCE
// and injects an import into its IIFE, so a child loaded as a classic script throws "Cannot use import statement
// outside a module" (measured on the staging page, 2026-10-03). So the root path comes first, served as the raw
// build; /public/<tree>/ stays last, for a plain static server of the repository, which transforms nothing.
for (const devFallback of ['/' + __DOXA_TREE_NAME__ + '/manifest.json', '/public/' + __DOXA_TREE_NAME__ + '/manifest.json']) {
if (!candidates.some(c => c.url === devFallback)) candidates.push({ url: devFallback, root: dirOf(devFallback) })
}
const tried = []
// Every mount waits for this gate (see FRESH BUNDLES above): it opens once the manifest's bundles are known
// to be current, when no manifest can be read (fail-soft, as before), or after 8 s whatever happens.
let open
this._freshGate = new Promise(res => { open = res })
setTimeout(open, 8000)
const attempt = (i) => {
if (i >= candidates.length) {
// Every candidate failed. Record it on the tabs so the PANE says so — see mountTab().
this.manifestError = 'manifest.json not found (tried ' + tried.join(', ') + ')'
console.error('[doxa-maps-spa] ' + this.manifestError +
' — every tab except Big Picture is composed from it, so their panes cannot mount.')
open()
return
}
const { url, root: treeRoot } = candidates[i]
tried.push(url)
// no-cache: always revalidated (a 304 when unchanged), so the manifest is never a stale copy.
return fetch(url, { credentials: 'same-origin', cache: 'no-cache' })
.then(r => { if (!r || !r.ok) throw new Error(url + ' → HTTP ' + (r ? r.status : '?')); return r.json() })
.then(m => {
if (!m) throw new Error(url + ' → empty')
this.manifestError = ''
// Anchor every tree-relative scriptUrl on the TREE ROOT this candidate names (the
// manifest's own directory, or the root for the copy). new URL() against location
// resolves '../' and rooted forms alike, so a caller-supplied treeBase, a built-tree
// path and the dev fallback all normalise here.
this.manifestBase = treeRoot || ''
this.revalidateBundles(m, treeRoot || '').then(open, open)
this.applyManifest(m)
})
.catch(() => attempt(i + 1))
}
try { attempt(0) } catch { open() /* fetch unavailable (non-browser): same fail-soft path */ }
},
// After a deploy (the manifest's fingerprint changed since this reader's last visit to this tree), ONE conditional
// request per listed bundle, so no stale cached copy is run (see FRESH BUNDLES). Never rejects.
revalidateBundles(m, root) {
const fp = treeFingerprint(m)
const prev = readTreeFp(root)
if (prev === fp) return Promise.resolve()
const urls = [...new Set(((m && m.bundles) || []).map(b => (b && b.scriptUrl) ? this.resolve(b.scriptUrl) : '').filter(Boolean))]
// Only a copy this browser already HOLDS can be stale, so the cache is asked first (only-if-cached never touches
// the network) and only those get the conditional request: a first visit with an empty cache costs nothing.
// only-if-cached needs a same-origin request; across origins, only a reader who has been here before is checked.
const sameOrigin = (u) => { try { return new URL(u, location.href).origin === location.origin } catch { return false } }
const held = (u) => (sameOrigin(u)
? fetch(u, { mode: 'same-origin', credentials: 'same-origin', cache: 'only-if-cached' })
.then(r => { if (r && r.body && r.body.cancel) r.body.cancel(); return !!(r && r.ok) }, () => false)
: Promise.resolve(prev !== null))
const revalidate = (u) => fetch(u, { credentials: 'same-origin', cache: 'no-cache' })
.then(r => { if (r && r.status === 200 && r.body && r.body.cancel) return r.body.cancel() })
const one = (u) => held(u).then(h => (h ? revalidate(u) : null)).catch(() => {})
return Promise.all(urls.map(one)).then(() => writeTreeFp(root, fp))
},
// Complete the curated tabs from the manifest, drop any whose bundle this tree does not ship,
// 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.mobileFlags = mobileFlagsFromManifest(m)
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
// never mount, and a rail row that mounts nothing is worse than a missing one (it reads as a
// broken app). navGroups already drops rows with no matching tab, so removing the tab is enough.
const kept = []
for (const t of this.tabs) {
if (t.discovered) { kept.push(t); continue }
const p = patch[t.id]
if (!p) continue
Object.assign(t, p, { resolved: true })
kept.push(t)
}
const have = new Set(kept.map(t => t.id))
if (this.showExperimental) for (const d of experimentalTabsFromManifest(m)) {
if (have.has(d.id)) continue
kept.push({ ...d, resolved: true, mounted: false, state: 'idle', error: '' })
}
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)
if (w >= 0 && !this.isDesktopOnly(want) && !this._honouredTab) this.select(w)
else this.select(i >= 0 && !this.isDesktopOnly(activeId) ? 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. The URL is read
// through useViewAddress: the first ?tab= wins, and a value that is not a plain id reads as absent.
requestedTab() {
let q = ''
try { q = readViewKeys(location.search).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.isDesktopOnly(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.
//
// ★ THIS ALSO USED TO POKE SLOT 0 BY HAND, writing a profile-config that named 'big-picture'
// onto slot 0's first child. It was stale in two ways at once and harmless in a third: tab-1 is
// `dashboard-plus-grid`, not `big-picture`, and back then every tab was an <iframe>, which
// ignores a profile-config attribute entirely. Being harmless is exactly why it survived — the
// watcher was doing the real work the whole time. Removed rather than repaired: a second
// hardcoded locale path is one more thing to keep in sync for no gain.
setLocale(code) {
this.locale = code
this.localeOpen = false
saveLocale(code)
},
// The language menu's 'switch': it fires only when no host cancelled doxa-language-request, so the
// gallery switches itself, and the pick holds over a `language` prop the host never updates.
pickLocale(code) {
this.setLocale(code)
this.sessionPick = code
},
// The shared rail emits the selected tab's `key` (its id); map it back to the index the
// pane-mount machinery below is keyed on.
//
// ★ THE DISABLED-ROW INTERCEPT. VerticalTabsLayout has no `disabled` concept of its own (its
// `pick(key)` emits update:active unconditionally on click — see the layout file's `pick`), and
// this shell does not own that file (see the studio-wide file-ownership rule), so Big Picture's
// "disabled on mobile" behavior is enforced HERE: the click still arrives, but on mobile it is
// turned into a transient message instead of a mount. The row stays visibly present (greyed via
// the `disabled`-badge styling below), never removed — exactly the device rule's "disable, don't
// hide."
onSelect(key) {
if (this.isDesktopOnly(key)) {
const t = this.tabs.find(x => x.id === key)
this.disabledNotice = (tStatic('messages.desktopOnlyNotice', this.uiLocale) || '{label} only supports desktop — open this page on a desktop to use it.')
.replace('{label}', t ? this.tabLabel(t) : key)
clearTimeout(this._noticeTimer)
this._noticeTimer = setTimeout(() => { this.disabledNotice = '' }, 4000)
return
}
const i = this.tabs.findIndex(t => t.id === key)
if (i !== -1) this.select(i)
},
// ★ THE PAGE ADDRESS (shareable pages, useViewAddress). A READER's pick in the rail is the one switch that
// writes ?tab=, as a push, so a reload keeps the tab, a sent link opens it, and Back returns to the previous one.
// Who writes is the page's urlOwner: 'app' (our own leaf page) writes history, 'host' (DX Life) gets a
// dxm-address event and its router writes, and a partner's page is never written. Every other switch is not a
// choice and writes nothing: the manifest's re-select, the device fallback, a host's `tab` attribute, Back and
// Forward. Nor is there a first-paint write: a bare URL means the device's default tab, and pinning a phone's
// default into the address would send a desktop reader to it.
onRailSelect(key) {
const before = this.activeKey
this.onSelect(key)
if (this.activeKey !== before) writeViewKeys({ tab: this.activeKey }, { owner: urlOwnerOf(this.cfg), push: true, el: this.$el })
},
// Back / Forward: the tab the address names, else the page's configured first tab (profile-config.tab), else the
// device default. Not the `tab` attribute: a host with a router re-points it from this same popstate, and until
// it does, it still names the tab being left.
openFromAddress(tab) {
const want = tab || String(this.cfg.tab || '').trim()
const i = want ? this.tabs.findIndex(t => t.id === want) : -1
this.select(i >= 0 && !this.isDesktopOnly(want) ? i : this.firstVisibleIndex())
},
// Resolve a tree-relative ref ("./x" / "x") against the tree base. If base is '' we keep it
// relative to THIS document — but this bundle's own folder is <base>/doxa-maps-spa/, so a
// co-located leaf at <base>/big-picture/... is one level UP: prefix '../'.
resolve(ref) {
const raw = String(ref)
// AN ALREADY-ABSOLUTE REF IS RETURNED UNTOUCHED. Identical rule (and identical prior bug) to
// plugin-studio.vue's resolve — the two cap-5 shells had byte-identical implementations, so
// they inherited the same defect. Measured 2026-09-22 with this SPA nested inside the studio
// under the dev server: 404 /doxa-research-map/doxa-research-map.js — a BUILT-tree path, the
// shape the old hardcoded TAB_DEFS used ("<bundle>/<profile>/index.html"). Under the built
// tree that is correct; under dev the manifest hands ROOTED refs (/plugin-designs/...),
// and prefixing a rooted ref is what produced a path that exists nowhere.
// A URL already anchored at the root needs no base by definition, so this is right for BOTH
// trees rather than a dev-only special case.
if (/^(https?:)?\/\//.test(raw) || raw.startsWith('/')) return raw
const r = raw.replace(/^\.\//, '')
// ★ TREE-RELATIVE REFS FOLLOW THE MANIFEST THAT SUPPLIED THEM, not this document.
// Every scriptUrl comes out of manifest.json as a tree-relative path ("<bundle>/<bundle>.js"),
// so it is only meaningful against the root the MANIFEST was read from. Those two roots are
// the same in the built tree and DIFFERENT under the dev server, where the page is served
// from /plugin-designs/ while the manifest lives at /public/<tree>/. Resolving against the
// document there produced 404 /plugin-designs/research-map-plus-grid/research-map-plus-grid.js
// — a path that exists nowhere. Anchoring on the manifest's own directory makes one rule
// correct for both trees (in the built tree manifestBase === base, so nothing changes).
if (this.manifestBase) return this.manifestBase.replace(/\/+$/, '') + '/' + r
// Join through exactly one slash: base() normalises to a trailing "/", but a caller-supplied
// tree-base may not, and a double slash 404s just as surely as a wrong prefix.
if (this.base) return this.base.replace(/\/+$/, '') + '/' + r
return '../' + r // relative fallback: sibling bundle folders are one level up
},
select(i) {
const prevIndex = this.activeIndex
this.activeIndex = i
// Release the OUTGOING heavy pane first, so at most one live map context exists at a time. Its
// v-if subtree is about to be torn down by the re-render anyway; resetting the tab's state here
// is what lets it re-mount correctly next time (a stale mounted:true would leave an empty slot
// forever, since mountElement skips an already-mounted tab).
if (prevIndex !== i && prevIndex >= 0) {
const prev = this.tabs[prevIndex]
if (prev && prev.heavy && prev.mounted) this.releaseTab(prevIndex, prev)
}
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
// ★ ON A PHONE, NOTHING MOUNTS BEFORE THE FLAGS ARE KNOWN. They come with the manifest, and the
// baked first tab (Big Picture) has its scriptUrl before that: mounting it here would put the
// heaviest, desktop-only map on a phone for the length of one request. Fail closed; applyManifest
// re-selects once the flags are in. On a desktop this changes nothing.
if (this.narrow && this.mobileFlags === null) return
if (this.isDesktopOnly(t.id)) return
// A heavy tab's slot only exists after its v-if subtree renders, so wait a tick before mounting
// into it: on the first activation the ref is still null at this point.
if (!t.mounted) this.$nextTick(() => { if (this.activeIndex === i) this.mountElement(i) })
},
// Destroy a pane's nested element. Removing the host node runs the element's disconnected
// lifecycle (onBeforeUnmount → map.remove()), which is what actually frees the GPU context — the
// node vanishing is not by itself proof the context was released.
releaseTab(i, t) {
try {
const slot = this._slots && this._slots[i]
if (slot) slot.innerHTML = ''
} catch { /* the tab resets either way; a failed cleanup must not wedge navigation */ }
if (this._slots) delete this._slots[i]
t.mounted = false
t.state = 'idle'
t.error = ''
},
// ★ withToken() WAS REMOVED WITH THE IFRAMES. It appended ?tk / ?tokenUrl / ?locale to a leaf
// PAGE url, because a query string was the only channel across an iframe boundary (window.
// MAP_TOKEN does not cross one). Mounting elements directly, all three ride in profile-config on
// the element itself — see mountElement — so there is nothing left to encode into a URL. It is
// 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 seen in the
// console (".../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
// ★ AND THE ENGINE. resolveMapProvider's ladder starts at profile-config.provider, and a child
// reads only the config written HERE — so a gallery asked for maplibre mounted children that
// resolved mapbox (bundler-2 measured 19 api.mapbox.com requests for a maplibre request on the
// studio, the same hole). Validated to the two known engines so a typo cannot pick a third.
if (this.effectiveProvider) pc.provider = this.effectiveProvider
// 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
// THE HOST OWNS ITS URL AND TITLE. A nested map must not write the page's hash, history or
// document.title: measured 2026-09-25, opening Regions on a host page stripped the host's own
// #anchor and retitled the host's browser tab "Regions". A child that syncs its state into the
// URL standalone should read this and stand down; children that never write the URL ignore it.
pc.urlSync = false
// The MAP's own name for its print header (mapPrintTemplate.js): this tab's localized label. A plugin that
// does not print ignores it.
pc.mapTitle = this.tabLabel(t)
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
// window.MAP_PROVIDER — the engine, when a host explicitly asked this gallery for one.
// childConfig forwards it as profile-config.provider, which the simple map elements honour,
// but a COMPOSITE mounts its inner maps without passing its own config's provider on.
// Measured 2026-09-24 with provider="maplibre", one tab per clean page: Prayer Progress and
// All People Groups made 0 api.mapbox.com requests, while Big Picture (19), Regions (17),
// UPG-100 (15) and Simple Research (14) loaded mapbox-gl and Mapbox styles. The page global
// is rung 3 of every map's resolveMapProvider ladder, so it reaches maps nested at any depth.
if (this.effectiveProvider && !window.MAP_PROVIDER) window.MAP_PROVIDER = this.effectiveProvider
},
// 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) {
const t = this.tabs[i]
t.mounted = true
t.state = 'loading'
const jsUrl = this.resolve(t.scriptUrl)
// Gate the mount on the TAG being registered, NOT on the script's onload — onload fires even
// when a dev ES module then fails to execute (see loadScriptOnce), which would hand us an inert
// HTMLUnknownElement and an empty pane cheerfully reporting 'ready'.
// …and on the freshness gate first, so a deploy's new bundle is the copy that runs (see FRESH BUNDLES).
Promise.resolve(this._freshGate).then(() => loadScriptOnce(jsUrl)).then(() => whenTagDefined(t.tag)).then(() => {
// Everything below is per-nested-plugin machinery; a throw here (a nested element that blows
// up in its connectedCallback, a serialise failure) must NOT crash the SPA — it lands in THIS
// tab's error pane. Hence the try/catch on top of the .catch() that handles a failed load.
try {
// A heavy tab may have been deactivated (and released) while its bundle loaded. Mounting
// into a torn-down pane would leak exactly the context this design exists to bound, so drop
// the mount and let the next select() redo it cleanly.
if (t.heavy && this.activeIndex !== i) { t.mounted = false; t.state = 'idle'; return }
const slot = this._slots && this._slots[i]
if (!slot) { t.state = 'error'; t.error = 'no slot'; return }
// Remove the loading hint, insert the real element with its profile-config.
slot.innerHTML = ''
const el = document.createElement(t.tag)
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
// instead (golden-path-check documents exactly this, and is the case that caught it).
// Such an element otherwise falls back to deriving the root from location.pathname —
// which is correct on its own <tree>/<bundle>/<profile>/ leaf page and WRONG once it is
// nested here, where the path belongs to the SHELL, not to the child. Measured: nesting
// golden-path-check produced 404 /manifest.json, while its standalone leaf was clean.
// An element that does not declare the prop simply ignores an unknown attribute, so this
// is safe for all of them — the same belt-and-braces the dev leaf route already applies.
if (this.base) el.setAttribute('tree-base', this.base.replace(/\/+$/, ''))
// FIXED-VIEWPORT (the default): pin the element to the pane exactly — inset:0 fills it and
// scrollHeight===clientHeight, so .dms-pane's overflow:auto (below) paints no scrollbar.
// SCROLL-STACK (t.scroll — dashboard-plus-grid, research-map-plus-grid): do NOT pin it. Both
// are built on the shared StackLayout, natural-height by design, and inset:0 would force
// them back to exactly one viewport regardless of real content height — the trap this fixes
// (see SCROLL_ELEMENTS above). static+height:auto lets the element grow the pane's
// scrollHeight past its clientHeight, which is what gives .dms-pane something to scroll.
// ★ min-height:0 IS LOAD-BEARING, AND height:100% DOES NOT IMPLY IT. CSS resolves a used
// height by CLAMPING the computed height between min-height and max-height, so a child's own
// min-height WINS over the height:100% we set here — it is a floor, not a preference. Nine
// bundles ship a root floor (research-dashboard 560px, gospel-internet 640px,
// wall-of-people-groups 600px, big-picture 480px, country-posters / my-upg-100-list 420px),
// and wherever the pane is shorter than that floor the pinned element grew TALLER than the
// pane it was supposed to fill. .dms-slot then grew with it and .dms-pane's overflow-y:auto
// (below) turned into a real scroll container — one that contains the WHOLE plugin, tab strip
// and all, so scrolling it scrolled the tab header out of view. That is the second scrollbar
// that was reported: not the plugin's content scroller, the PANE scrolling the
// plugin bodily. The invariant two comments below ("fills the pane exactly, so
// scrollHeight===clientHeight") was written as a description and was only ever a hope.
// An inline style outranks the child's own :host rule, so this one property restores the
// invariant for every fixed-viewport plugin at once, without editing any of them. A floor set
// on an element INSIDE the child's shadow root is out of reach from here and stays its
// owner's to fix (research-dashboard's .rd-shell, gospel-internet's .gb).
// NOT added to the scroll-stack branch: those are natural-height by design and a floor is
// exactly what they are entitled to.
el.style.cssText = t.scroll
? 'display:block;position:static;width:100%;height:auto;'
: 'display:block;position:absolute;inset:0;width:100%;height:100%;min-height:0;'
slot.appendChild(el)
t.state = 'ready'
} catch (e) {
t.state = 'error'
t.error = 'mount failed: ' + ((e && e.message) || String(e))
}
}).catch((e) => {
t.state = 'error'
t.error = (e && e.message) || String(e)
})
}
}
})
</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; }
/* ★ THE HOST'S TYPOGRAPHY STOPS AT THE BOX. Shadow DOM isolates SELECTORS, not INHERITANCE: every
inherited property on the host page flows straight into the gallery and into every nested map.
Measured 2026-09-25 on a host with `body{font:20px/3 Georgia; letter-spacing:2px}`: the gallery
root computed font-size 20px, line-height 60px, letter-spacing 2px — every rail row and map legend
re-flowed by the partner's blog styles. These are reset to the values the gallery is designed
against (its own leaf page's defaults). `direction` is not reset here: the layout root carries an
explicit dir from the gallery's language (uiDir), which is what actually decides RTL. Family and
colour are set by the layout itself (.vtl). */
:host {
font-size: 16px; line-height: normal; font-weight: 400; font-style: normal; font-variant: normal;
font-stretch: normal; letter-spacing: normal; word-spacing: normal; text-transform: none;
text-align: start; text-indent: 0; text-shadow: none; white-space: normal; hyphens: manual;
visibility: visible; cursor: auto;
}
: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.
(.dms-frame went with the iframes: there is no framed pane left to style.)
★ THE PANE OWNS THE SCROLL (the grid under a composite could not be scrolled to). .dms-pane was
`position:absolute; inset:0` with no overflow, and .dms-slot the same — so a tall composite
(dashboard-plus-grid, the Big Picture tab) grew the SLOT's scrollHeight past the PANE's
clientHeight with nothing to scroll it: an absolutely-positioned child cannot grow its own
scroll container. This is the exact same trap library/layouts/VerticalTabsLayout.vue already
fixed on .vtl-host/.vtl-host-slot (see that file's comment) — this SPA does NOT go through that
layout's `element` row mechanism (it mounts its own custom elements into #default panes here),
so the library fix never reached this copy. Porting the same proven shape:
.dms-pane gets overflow-y:auto (+ overscroll-behavior:contain so a bottomed-out wheel does
not chain to the document) instead of plain inset:0 with no scroller.
.dms-slot becomes position:relative; min-height:100% instead of inset:0, so it can grow
taller than the pane (letting scrollHeight exceed clientHeight) while still
filling the pane at minimum.
SAFE FOR THE FIXED-VIEWPORT MAP TABS (the majority): a map ships position:absolute;inset:0 (or
height:100%) and fills the pane exactly, so scrollHeight===clientHeight and overflow:auto paints
NO scrollbar — nothing changes for them. Only a pane whose content genuinely exceeds one
viewport (the Big Picture dashboard+grid stack) gets a scrollbar, which is the one that needs it.
Do NOT go the other way and remove .dms-pane's absolute/inset — that was measured and produced a
worse defect (grid landing far below the fold); overflow:auto on a BOUNDED pane is correct. */
/* ★ --profile-min-h:0 IS THE THIRD LINE OF THE SHAPE ABOVE, AND IT IS THE ONE THAT WAS LEFT BEHIND.
library/ProfileLoader.vue:336 floors EVERY profile at `min-height: var(--profile-min-h, 100dvh)`.
That floor measures against the VIEWPORT while this pane measures against its HOST, so on any page
with chrome above the gallery the profile is taller than the pane by exactly the height of that
chrome -- and .dms-pane's overflow-y:auto then scrolls the whole plugin, tab strip included.
MEASURED on DX Life: 1440x900 viewport, 794px pane, content exactly 900 (106px over, the
header); 390x844, 712px pane, content exactly 844 (132px). On 12 of 16 tabs, INCLUDING plugins with
no min-height of their own -- which is what proves the floor is ProfileLoader's, not theirs.
It cannot be fixed from outside: the floor is in the element's SHADOW ROOT where no host-page
selector reaches, so an inherited custom property is the only lever a host has. ProfileLoader
documents this opt-out for exactly this case, and .vtl-host and plugin-studio's .ps-pane both set
it. This pane was ported from .vtl-host and took two of its three lines. */
.dms-pane { position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; --profile-min-h: 0; }
/* A tab whose free corner lies over a positioned bar of the map's own (Big Picture's filter bar, z-index 55)
raises the info BUTTON above that bar (src/tabInfo.js pos.raise). Only the button: the one-time pointer
stays under the bar, so it covers no filter even visually. Measured: nothing interactive under the button,
the button topmost at its centre, and a click opens the popup, at 1440, 1024 and 800, en and ar. */
.mi.dms-info-raise .mi-btn { z-index: 60; }
.dms-slot { position: relative; min-height: 100%; }
.dms-hint {
position: absolute; inset: 0; display: grid; place-items: center; text-align: center;
color: #6d766e; font-size: 13px; padding: 20px;
}
.dms-hint.err { color: #f87171; }
.dms-hint small { color: var(--dx-muted, #6d766e); }
/* THE HELP DIALOG — opened by a map's own "?" (doxa:help); see the template comment. */
.dms-help-close:focus-visible { outline: 2px solid #3f8f5a; outline-offset: 2px; }
.dms-help-scrim { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.35); padding: 16px; }
.dms-help {
max-width: 420px; width: 100%; color: #3b463d; background: #fff; border-radius: 12px;
padding: 18px 20px 14px; box-shadow: 0 12px 32px rgba(0,0,0,.2); font-size: 14px; line-height: 1.5;
}
.dms-help-title { margin: 0 0 8px; font-size: 16px; }
.dms-help-what, .dms-help-how { margin: 0 0 10px; }
.dms-help-close { float: right; font: inherit; padding: 6px 14px; border-radius: 8px; border: 1px solid #d1d4cd; background: #f3f3f1; cursor: pointer; }
/* THE DEVICE-RULE NOTICE — the transient "desktop only" toast for the disabled Big Picture row on
mobile. Material light only (surface/border/text/muted tokens), matching the rest of this file. */
.dms-notice {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 40;
max-width: 320px; text-align: center; font-size: 13px; line-height: 1.4;
color: #3b463d; background: #fff; border: 1px solid #d1d4cd; border-radius: 10px;
padding: 10px 16px; box-shadow: 0 8px 24px rgba(0,0,0,.12);
}
/* "Not built for mobile" (a "larger-screens" map): sticky at the top of the pane's own scroller, so it stays in
view over the map and never covers the rail. Dismissible per map for the session. */
.dms-mobile-notice {
position: sticky; top: 0; z-index: 30; display: flex; align-items: flex-start; gap: 8px;
padding: 10px 12px; font-size: 13px; line-height: 1.4; color: #3b463d;
background: #fff8e6; border-bottom: 1px solid #f0d58c;
}
.dms-mobile-notice span { flex: 1; }
.dms-mobile-notice-x {
flex: none; min-width: 32px; min-height: 32px; border: 0; border-radius: 6px;
background: transparent; color: inherit; font-size: 18px; line-height: 1; cursor: pointer;
}
.dms-mobile-notice-x:focus-visible { outline: 2px solid #3b463d; outline-offset: 1px; }
/* #brand logo mark (left of the shell title). */
.dms-logo { display: inline-flex; align-items: center; }
.dms-logo svg { display: block; }
/* #toolbar i18n locale switcher — matches the admin shell tokens (neutral surfaces, green accent). */
.dms-locale { position: relative; padding: 8px 12px 0; display: flex; justify-content: flex-end; }
.dms-locale-btn {
display: inline-flex; align-items: center; gap: 8px; font: inherit; font-size: 12px;
padding: 5px 10px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd;
color: #3b463d; cursor: pointer;
}
.dms-locale-btn:hover { background: #d1d4cd; }
.dms-flag { font-size: 14px; line-height: 1; }
.dms-locale-name { max-width: 12ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dms-dots { color: #6d766e; margin-left: 2px; }
.dms-locale-menu {
position: absolute; top: 100%; right: 12px; margin-top: 4px; z-index: 30; min-width: 180px;
list-style: none; padding: 6px; border: 1px solid #d1d4cd; border-radius: 10px;
background: #ffffff; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto;
}
.dms-locale-opt {
display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit;
font-size: 13px; padding: 7px 9px; border: 0; border-radius: 7px; background: none;
color: #3b463d; cursor: pointer;
}
.dms-locale-opt:hover { background: #d1d4cd; color: #3b463d; }
.dms-locale-opt.active { background: #d1d4cd; color: #3b463d; }
.dms-locale-opt.active .dms-locale-name { color: #92b195; }
.dms-locale-en { margin-left: auto; font-size: 11px; color: #6d766e; }
</style>