Regions is a standalone app now: URL is the state, 4/10 -> 10/10
Some checks are pending
build-gate / build (push) Waiting to run

MEASURED first, with _qa-cycle/02-builder-cycle/_probe-standalone-app.mjs: 4 of
10 app properties OK. Five of the six failures were ONE missing thing — the URL
never reflected the selection. So nothing was linkable, a reload lost the
subject, Back unmounted the element, a deep link did nothing, and Share shared
the index. DX-life gets all of it free from Nuxt routing; /regions/oceania IS
the state. A custom element has to do it deliberately.

THE HASH, NOT THE PATH: a leaf page is a static file at a fixed URL with no
server to route /regions/oceania, and the hash is the only part an EMBEDDED
element may touch without fighting its host page. Slugs are DX-life's own, so a
link reads the way theirs does. replaceState on first paint, pushState on a user
choice — that is what makes Back mean "the previous subject" rather than "leave
the page". A slug resolves region-first then country, the same order their
[slug].vue matches in; "neither" lands on the chooser rather than a 404, because
for this element the chooser is a legitimate state.

The hash is applied AFTER the fetch, not on mount: it cannot be resolved to a
subject until the regions are known, and resolving early sent every deep link
silently to the chooser.

One watcher on the selection rather than a writeUrl() call at each of the five
places a selection is made (tab-1 event, table row, two dropdowns, back-link) —
those would drift. popstate sets a guard so it cannot push an entry for a state
it just applied, which is the classic loop where Back appears to do nothing.

Also: document.title follows the subject, as theirs does via useSeoMeta, and the
scroll body is a <main> landmark — the probe measured none on the page.

★ THREE OF MY OWN ASSERTIONS WERE WRONG BEFORE THE CODE WAS, each failing on
correct behaviour, and each time my first instinct was that the app was broken:
  · h1/share/landmark counts were read AFTER a goBack had unmounted the element,
    so "0 h1s" was a reading of a blank page. A reading taken in an unknown
    state is not a finding.
  · the Back test clicked the SAME region twice — selecting the subject you are
    already on correctly creates no history entry.
  · the next version clicked two table rows, but picking a COUNTRY renders
    CountryDetail, which has no region table, so the second click hit nothing
    and both picks read identical.
It now sequences only through controls that exist in each state: pick a region,
clear with the in-page back-link, Back returns the region.

No regression: region page still 68/68 vs live, country 28/28, both browser
suites green, no gate newly red.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 02:20:37 +00:00
parent 2edb83857e
commit b9f6ecf12e
7 changed files with 119 additions and 37 deletions

View file

@ -22,7 +22,7 @@
* config/countries-meta.ts. Copied rather than imported for the same reason stated there — and
* kept field-for-field identical so both tabs roll up the same way.
*/
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { inject, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useDocumentFont, BEBAS_NEUE_FACES, POPPINS_FACES } from '@map/composables/useDocumentFont.js'
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
@ -405,6 +405,79 @@ const mapNote = computed(() => {
// of ours. It fetches and filters the people groups itself, exactly as theirs does, so there is
// no countryTable computed here any more.
// ── URL <-> SELECTION: what makes this a standalone app rather than a widget ─
// MEASURED before this existed (_qa-cycle/02-builder-cycle/_probe-standalone-app.mjs): 4 of 10
// app properties OK. Five of the six failures were ONE missing thing — the URL never reflected
// the selection, so nothing was linkable, a reload lost the subject, Back unmounted the element,
// a deep link did nothing, and Share shared the index. DX-life gets all of that free from Nuxt
// routing (/regions/oceania IS the state); a custom element has to do it deliberately.
//
// THE HASH, NOT THE PATH. A leaf page is a static file at a fixed URL and there is no server to
// route /regions/oceania, so the subject lives in the hash — and the hash is also the only part
// an EMBEDDED element may touch without fighting its host page. Slugs are DX-life's own
// (#oceania, #cameroon), so a link reads the way theirs does.
// replaceState on the first paint, pushState on a user choice: that is what makes Back mean
// "the previous subject" instead of "leave the page".
const URL_SYNC = typeof window !== 'undefined' && typeof history !== 'undefined'
function slugFromHash() {
if (!URL_SYNC) return ''
return decodeURIComponent(String(window.location.hash || '').replace(/^#/, '')).trim()
}
// A slug may name a region OR a country — DX-life's [slug].vue matches region slugs first, then
// country slugs, and 404s on neither. Same order here; "neither" lands on the chooser rather
// than an error, because the chooser is a legitimate state for this element.
function selectionForSlug(slug) {
if (!slug) return { regionSlug: '', countrySlug: '' }
const r = regions.value.find((x) => x.slug === slug)
if (r) return { regionSlug: r.slug, countrySlug: '' }
for (const reg of regions.value) {
const c = reg.countries.find((x) => x.slug === slug)
if (c) return { regionSlug: reg.slug, countrySlug: c.slug }
}
return { regionSlug: '', countrySlug: '' }
}
function currentSlug() {
return sel.value.countrySlug || sel.value.regionSlug || ''
}
// Write the subject into the URL. push:true for a user choice (Back should undo it), false for
// the initial read (Back should leave the page, not replay the boot).
function writeUrl(push) {
if (!URL_SYNC) return
const slug = currentSlug()
const url = window.location.pathname + window.location.search + (slug ? '#' + encodeURIComponent(slug) : '')
try { push ? history.pushState({ slug }, '', url) : history.replaceState({ slug }, '', url) } catch (_) {}
syncTitle()
}
// Their page sets the document title per subject (useSeoMeta in [slug].vue: `${title} — Doxa`).
// A standalone app should too: it is what a bookmark, a tab strip and a shared link all show.
function syncTitle() {
if (typeof document === 'undefined') return
const t = subject.value?.title
try { document.title = t && t !== 'All Regions' ? t + ' — Regions' : 'Regions' } catch (_) {}
}
// popstate WRITES the selection, so the watcher below must not turn around and push a new
// entry for it — that is the classic history loop where Back appears to do nothing.
let applyingHistory = false
function onPopState() {
applyingHistory = true
sel.value = selectionForSlug(slugFromHash())
syncTitle()
applyingHistory = false
}
// One watcher rather than a writeUrl() call at each of the five places a selection is made
// (tab-1 event, table row, region dropdown, country dropdown, back-link) — those would drift.
watch(() => [sel.value.regionSlug, sel.value.countrySlug], () => {
if (applyingHistory) return
writeUrl(true)
}, { flush: 'post' })
// ── in-dashboard navigation ─────────────────────────────────────────────────
// Their back-link (RegionDetail.vue:48) goes to /regions. We have no router, so it drops the
// selection, which lands on the ALL REGIONS subject -- the closest thing this element has to
@ -459,13 +532,20 @@ async function loadRegions() {
}
}
onMounted(() => {
onMounted(async () => {
window.addEventListener(SELECT_EVENT, onSelect)
loadRegions()
if (URL_SYNC) window.addEventListener('popstate', onPopState)
// The hash can only be resolved to a subject once the regions are known, so it is applied
// after the fetch rather than on mount — otherwise a deep link silently lands on the chooser.
await loadRegions()
const slug = slugFromHash()
if (slug) sel.value = selectionForSlug(slug)
writeUrl(false)
})
onBeforeUnmount(() => {
window.removeEventListener(SELECT_EVENT, onSelect)
if (URL_SYNC) window.removeEventListener('popstate', onPopState)
})
// Palette is DX-life material light, matching regions-list.vue tab-for-tab so swapping between
@ -638,7 +718,9 @@ useShadowStyles(
<template>
<div class="rd-root">
<div class="rd-scroll">
<!-- a landmark, so a screen reader can jump to the dashboard body; the app probe
measured none on the page and it costs nothing to name the region. -->
<main class="rd-scroll" aria-label="Region dashboard">
<div v-if="loading" class="rd-status">Loading region dashboards…</div>
<div v-else-if="loadFailed" class="rd-status rd-failed" role="status">
Region data could not be loaded. This is a connection problem, not a filter.
@ -804,6 +886,6 @@ useShadowStyles(
:language-code="languageCode"
/>
</template>
</div>
</main>
</div>
</template>

File diff suppressed because one or more lines are too long

View file

@ -838,7 +838,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-w2ziHedwIve052fPDRQunBNtnLfQan1zNT7RzWU1HFITcozprdbnTDKru/1Szqcm",
"integrity": "sha384-TVO7+r7hZgEnCZo1wxlSkoGjh8U0UrQ1St3W9KPAP4jyOxfpO6njV/CQMCf9ptG8",
"elements": [
"regions-dashboard"
],
@ -858,7 +858,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-w2ziHedwIve052fPDRQunBNtnLfQan1zNT7RzWU1HFITcozprdbnTDKru/1Szqcm\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-TVO7+r7hZgEnCZo1wxlSkoGjh8U0UrQ1St3W9KPAP4jyOxfpO6njV/CQMCf9ptG8\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -872,7 +872,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-w2ziHedwIve052fPDRQunBNtnLfQan1zNT7RzWU1HFITcozprdbnTDKru/1Szqcm\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-TVO7+r7hZgEnCZo1wxlSkoGjh8U0UrQ1St3W9KPAP4jyOxfpO6njV/CQMCf9ptG8\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -886,7 +886,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-w2ziHedwIve052fPDRQunBNtnLfQan1zNT7RzWU1HFITcozprdbnTDKru/1Szqcm\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-TVO7+r7hZgEnCZo1wxlSkoGjh8U0UrQ1St3W9KPAP4jyOxfpO6njV/CQMCf9ptG8\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-w2ziHedwIve052fPDRQunBNtnLfQan1zNT7RzWU1HFITcozprdbnTDKru/1Szqcm" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-TVO7+r7hZgEnCZo1wxlSkoGjh8U0UrQ1St3W9KPAP4jyOxfpO6njV/CQMCf9ptG8" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-w2ziHedwIve052fPDRQunBNtnLfQan1zNT7RzWU1HFITcozprdbnTDKru/1Szqcm" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-TVO7+r7hZgEnCZo1wxlSkoGjh8U0UrQ1St3W9KPAP4jyOxfpO6njV/CQMCf9ptG8" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-w2ziHedwIve052fPDRQunBNtnLfQan1zNT7RzWU1HFITcozprdbnTDKru/1Szqcm" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-TVO7+r7hZgEnCZo1wxlSkoGjh8U0UrQ1St3W9KPAP4jyOxfpO6njV/CQMCf9ptG8" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: