Restore three-section tab structure: Dashboards / Prayer Mobilization / Gospel Research

Big Picture now lives in its own Dashboards section alongside a renamed Simple
Research (was Research Map, recategorised as the mobile-default dashboard).
Prayer Mobilization narrows to UPG-100 List + Prayer Progress; the renamed
Gospel Research section takes Cities with Internet + Gospel Resources.

The device rule changes shape to match: every row now stays in the rail on
every width (nothing is hidden any more), Big Picture is disabled rather than
removed on mobile with a "desktop only" badge and an explanatory toast on
click, and the auto-selected tab swaps to Simple Research on mobile / Big
Picture on desktop.

Added a loud, module-load-time assertion that no tab id is declared twice
across the curated sections, on top of the existing silent runtime dedupe --
this is the gate the duplicate-key regression needed and didn't have.
This commit is contained in:
doxa-maps-88655c70 2026-09-23 15:21:33 +00:00
parent c48d1d401a
commit 4c74bd717e
4 changed files with 186 additions and 94 deletions

View file

@ -133,6 +133,12 @@
</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>
</VerticalTabsLayout>
</template>
@ -148,66 +154,57 @@ import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/la
// 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 = [
// ── NOTE: there is deliberately NO separate "Dashboards" section. The ONE dashboard (the Driver's
// ruling: "we don't need multiple dashboards") is `dashboard-plus-grid` — the coupled
// research-dashboard + people-groups-grid on a single scroll-page, what the Driver calls the Big
// Picture. It is the FIRST row of Prayer Mobilization below, which makes it tab-1/the landing
// surface AND the opening step of the onboarding ramp.
// ── THREE SECTIONS, PER THE SPEC (2026-09-22 audio, transcribed to
// _qa-cycle/01-ideation-research/SPEC-the-six-map-product-and-its-tab-order.md — that file is
// AUTHORITATIVE; this comment explains how the code satisfies it, not the ruling itself).
//
// WHY IT IS NOT ALSO ITS OWN SECTION: it used to be, and that is exactly what painted "Big
// Picture" TWICE in the rail (a one-row "Dashboards" section whose single row was re-declared as
// the ramp's step 1). Two rows sharing one tab id is not merely cosmetic — the layout keys its
// tab-ref map and its `active` class BY ID, so both copies highlight at once and keyboard nav
// stalls on the repeated key. One section owning the row fixes all of that and matches the
// Driver's stated order (Big Picture → UPG-100 → Prayer Progress → Gospel Resources → Research).
// Driver, verbatim: "in one of the earlier messages i mentioned i wanted 3 tab sections. one
// called dashboards, one called prayer mobilization, one called gospel research. with two maps
// inside of each of them."
//
// Prayer Mobilization FIRST among the map sections (the prayer map is the mobilisation landing).
//
// ORDER IS THE DRIVER'S, AND THE ORDER IS THE TEACHING. Big Picture → UPG-100 List → Prayer
// Progress → Gospel Resources. The non-obvious step is UPG-100 BEFORE the prayer map, and his
// rationale is worth preserving verbatim: "if you're trying to mobilize prayer, UPG-100 list
// probably comes first because it's a NEW thing to people — you want them introduced to it
// first." i.e. the rail is an ONBOARDING RAMP, not an inventory: show the big picture, hand the
// newcomer the concrete new artifact (the 100 list), THEN ask them to pray it, then give them
// the resources to act. Re-sorting these rows by any other logic (alphabetical, "maps first",
// most-used) silently breaks that ramp — so do not reorder without the Driver.
// ★ DESKTOP vs MOBILE — `onlyWhen` gates a row on viewport width (Driver 2026-09-22, verbatim).
// DESKTOP: Big Picture Dashboard opens by default, then UPG-100, Prayer Progress, Gospel
// Resources, Cities & Internet, then Research Map under Research.
// MOBILE: "starts with the Research Map, then UPG-100 List, Prayer Progress, Gospel Resources,
// Cities & Internet — and the Big Picture Dashboard does NOT appear on mobile at all."
// So the ramp's FIRST rung swaps: the desktop rung is the dashboard composite, the mobile rung is
// the research-map composite. Everything below rung 1 is IDENTICAL on both, which is why only the
// first two rows carry `onlyWhen` — do not sprinkle it further without the Driver.
//
// WHY A ROW-LEVEL FLAG rather than two NAV_MODELs: one model keeps the teaching order in ONE place.
// Two parallel models would be two things to keep in sync, and the failure would be silent (a row
// added to one and forgotten in the other simply never appears on that form factor).
// 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', 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 isBigPictureDisabled /
// 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' },
] },
// 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 still the Driver's deliberate order —
// "if you're trying to mobilize prayer, UPG-100 list probably comes first because it's a NEW thing
// to people — you want them introduced to it first" — hand the newcomer the concrete new artifact,
// THEN ask them to pray it.
{ key: 'prayer-mobilization', label: 'Prayer Mobilization', icon: 'heart',
tabs: [
// 1a. DESKTOP rung 1 — Big Picture. Omitted entirely on mobile, per the Driver.
{ id: 'dashboard-plus-grid', icon: 'chart', onlyWhen: 'desktop' },
// 1b. MOBILE rung 1 — the research map + grid composite opens the app instead.
{ id: 'research-map-plus-grid', icon: 'map', onlyWhen: 'mobile' },
{ id: 'upg-100', icon: 'list' }, // 2. UPG-100 List — the NEW thing, introduced early
{ id: 'prayer-map', icon: 'embed' }, // 3. Prayer Progress — now pray the list
{ id: 'gospel-resources', icon: 'book' }, // 4. Gospel Resources — then act
{ id: 'upg-100', icon: 'list' }, // 1. UPG-100 List — the NEW thing, introduced first
{ id: 'prayer-map', icon: 'embed' }, // 2. Prayer Progress — now pray the list
] },
// Research — a peer section (nav.js: "a section to the same degree as Laborer"). The Driver's
// order: Cities & Internet FIRST, then Gospel Resources, then the simple research app at the
// bottom.
//
// ★ Gospel Resources is DECLARED here as well as in the prayer ramp above, on purpose — it is the
// "then act" step of that ramp AND a research surface in its own right. It is declared twice but
// RENDERED ONCE: dedupeRows() (see below) gives the FIRST declaration the row and drops this
// later one, so the rail holds exactly one node per tab id. Leaving the declaration here is not
// dead weight — it documents the intent, and if the prayer section is ever re-cut this section
// inherits the row automatically.
{ key: 'research', label: 'Research', icon: 'search',
// 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', icon: 'search',
tabs: [
{ id: 'cities-internet', icon: 'wifi' },
{ id: 'gospel-resources', icon: 'book' },
{ id: 'research-map', icon: 'map' }, // the simple research app, at the bottom
{ 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.
@ -283,16 +280,22 @@ const TAB_DEFS = [
bundle: 'doxa-research-map', profile: 'research-map'
},
{
// THE MOBILE COUNTERPART OF 'dashboard-plus-grid'. Driver 2026-09-22, having reversed an earlier
// idea: "We made the wrong choice of saying that we should swap elements based on mobile and
// desktop... It'd be easier just to have two separate capability-4 plugins." So there are two
// cap-4 composites, neither aware of the other, and THIS SHELL picks between them by width —
// which is what `onlyWhen` below expresses. Both stay independently embeddable, which a
// self-swapping element could not be (an embedder at a fixed size could never ask for one).
// RENAMED (spec): "Research Map" → "Simple Research", and RECATEGORISED as a Dashboards row (the
// mobile-default dashboard). Driver 2026-09-22, having reversed an earlier idea: "We made the
// wrong choice of saying that we should swap elements based on mobile and desktop... It'd be
// easier just to have two separate capability-4 plugins." 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/isBigPictureDisabled), 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: 'Research Map', sub: 'research map + people-groups grid', emoji: '🗺️',
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'
},
{
@ -310,7 +313,9 @@ const TAB_DEFS = [
bundle: 'doxa-simple-map', profile: 'doxa-simple-map', instance: 'prayer'
},
{
id: 'cities-internet', label: 'Cities & Internet', sub: 'connectivity of key cities', emoji: '📡',
// 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'
},
{
@ -327,6 +332,11 @@ const TAB_DEFS = [
// 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.
@ -421,6 +431,40 @@ function dedupeRows(rows, seen) {
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
}
// 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) {
@ -627,14 +671,18 @@ export default defineComponent({
// 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 desktop/mobile tab swap (see NAV_MODEL onlyWhen).
// ★ 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 show the Big Picture row inside a mobile drawer, which is
// the rail has become a drawer would leave Big Picture enabled inside a mobile drawer, which is
// precisely what the Driver asked to never happen.
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,
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
locale: loadLocale(),
localeOpen: false,
@ -676,6 +724,13 @@ export default defineComponent({
// 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 — "it shouldn't change the tab order, it should open you up to
// the 2nd tab by default on mobile and disable the 1st tab." 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 isBigPictureDisabled) and
// which row auto-opens (see firstVisibleIndex) — both presentation, neither touches NAV_MODEL.
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
@ -687,15 +742,20 @@ export default defineComponent({
.map(g => {
let rows = dedupeRows(
(g.tabs || [])
// ★ WIDTH GATE FIRST, before the tab-exists filter. A row may declare
// onlyWhen:'desktop'|'mobile'; anything else (the common case, no flag) shows on both.
// Applied here rather than in `tabs` so the underlying TAB_DEFS/mount machinery is
// untouched — the swap is PURE PRESENTATION, exactly like the sections themselves.
.filter(r => !r.onlyWhen || r.onlyWhen === (this.narrow ? 'mobile' : 'desktop'))
.filter(r => byId[r.id])
.map(r => {
const t = byId[r.id]
return { key: t.id, label: t.label, sub: t.sub, icon: r.icon, badge: r.badge }
// 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 isBigPictureDisabled), 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.isBigPictureDisabled(t.id)
return {
key: t.id, label: t.label, sub: t.sub, icon: r.icon,
badge: disabled ? 'desktop only' : r.badge, disabled
}
}),
seen)
// The Experimental folder is not authored — it is FILLED with every discovered tab (any
@ -786,7 +846,7 @@ export default defineComponent({
},
methods: {
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
// ── VIEWPORT WATCH (the desktop/mobile tab swap) ──────────────────────────────────────────
// ── 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.
//
@ -801,11 +861,11 @@ export default defineComponent({
const wasNarrow = this.narrow
this.narrow = !!e.matches
if (wasNarrow === this.narrow) return
// A breakpoint crossing can REMOVE the row that is currently active (Big Picture exists only
// on desktop). Leaving activeIndex pointing at a row the rail no longer paints leaves the
// user on a mounted-but-unlisted tab with no highlight — visibly broken. Snap to the first
// row that survives the crossing.
if (!this.isVisibleId(this.activeKey)) this.select(this.firstVisibleIndex())
// 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.isBigPictureDisabled(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
@ -813,21 +873,30 @@ export default defineComponent({
if (this._mql.addEventListener) this._mql.addEventListener('change', this._onMedia)
else if (this._mql.addListener) this._mql.addListener(this._onMedia)
},
// Is this tab id painted in the rail at the CURRENT width? Derived from navGroups (the thing the
// user actually sees) rather than re-deriving the onlyWhen rule, so the two can never disagree.
isVisibleId(id) {
return this.navGroups.some(g => g.rows.some(r => r.key === id))
// THE ONE ROW THE DEVICE RULE DISABLES. Driver, verbatim: "it shouldn't change the tab order, it
// should open you up to the 2nd tab by default on mobile and disable the 1st tab." Named as a
// predicate (not a hardcoded width filter in navGroups) so the ONE place that knows "Big Picture"
// means 'dashboard-plus-grid' is this method — navGroups, onSelect and watchWidth all call it
// rather than each re-encoding the id + the width test.
isBigPictureDisabled(id) {
return id === 'dashboard-plus-grid' && this.narrow
},
// Index (into this.tabs) of the first row the rail currently paints. Falls back to 0 so the app
// always opens something, even if a future NAV_MODEL edit leaves a width with no curated rows.
// 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) return i
if (i < 0) continue
if (fallback < 0) fallback = i
if (!r.disabled) return i
}
}
return 0
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
@ -888,7 +957,21 @@ export default defineComponent({
},
// 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.isBigPictureDisabled(key)) {
this.disabledNotice = 'Big Picture only supports desktop — open this page on a desktop to use it.'
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)
},
@ -1065,6 +1148,15 @@ export default defineComponent({
.dms-hint.err { color: #f87171; }
.dms-hint small { color: var(--dx-muted, #6d766e); }
/* 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);
}
/* #brand logo mark (left of the shell title). */
.dms-logo { display: inline-flex; align-items: center; }
.dms-logo svg { display: block; }

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../../doxa-maps-spa.js" integrity="sha384-GJiuI3T69Om//h3hE5AEeqIF3H6EnXrQVNj5BiAyLZcABodDXAA/pDYVxXsDT1Pq" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-IWw27w4r5O6tsh92zPMqVoxt4so2pmIjsJBu/G8XQ0S3QEyEGKIW1QykAdRDioQA" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../doxa-maps-spa.js" integrity="sha384-GJiuI3T69Om//h3hE5AEeqIF3H6EnXrQVNj5BiAyLZcABodDXAA/pDYVxXsDT1Pq" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-4DqIPeaMZZODDw5iecKF7xbDq1t11yMC9Sppgik+YhOpGyb45Pfx/8MysX8WEnjO" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: