doxa-maps/plugin-designs/regions-dashboard/regions-list.vue
Doxa Maps e3d25ff6db The people-group list is a universal prop: the same maps can show any project's list
Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
  profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').

- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
  in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
  to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
  counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
  (71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
  The default list returns the same array, unfetched. A list whose membership cannot load shows no
  groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
  their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
  Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
  are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
  the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
  thread `list` to their children like `provider`.

Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.

Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
2026-09-26 05:16:02 +00:00

744 lines
50 KiB
Vue

<script setup>
/**
* regions-list.vue — Tab 1 ("Regions List") of the regions-dashboard bundle.
*
* @description A refactor of DX-life's /regions index page (app/pages/regions/index.vue) into
* an owned, self-contained doxa-maps custom element: one card per WAGF region, each holding
* its countries as a responsive list. Independently scrollable (the shell hosts this beside a
* sibling "Region Dashboards" tab and swaps between them). Clicking a region heading or a
* country row does NOT navigate — it EMITS a selection so the shell can switch to tab 2.
*
* SOURCE (READ-ONLY, copy-only — root-dx-life/doxa-life is standing law, zero edits):
* root-dx-life/doxa-life/app/pages/regions/index.vue — the layout being ported
* root-dx-life/doxa-life/server/api/regions.get.ts — the /api/regions contract
* root-dx-life/doxa-life/config/countries-meta.ts — summarizeRegions() + fields
*
* WHY THIS FETCHES AND GROUPS ITSELF rather than calling /api/regions: that endpoint is a Nitro
* server route inside the DX-life Nuxt app and is not reachable from a standalone embed. The
* SAME live people-groups endpoint people-groups-grid.vue already uses (pray.doxa.life) carries
* the same wagf_region / country_code fields the Nuxt route reads, so grouping happens here,
* client-side, using the identical fields (PEOPLE_GROUP_LIST_FIELDS) and the identical rollup
* shape (one row per country, rolled into one card per region) as countries-meta.ts's
* summarizeRegions().
*
* ★ AND IT IS NOW ONE IMPLEMENTATION, NOT A COPY. This paragraph used to say the grouping was a
* COPY of countries-meta.ts "not an import", because DX-life's file is a .ts module with a
* Nuxt-only COUNTRIES import that cannot resolve outside that app. That was true when it was
* written and it stopped being true when the file was ported to
* library/utils/countries-meta.js: BOTH tabs now import summarizeRegions and
* PEOPLE_GROUP_LIST_FIELDS from there, and grep finds exactly one definition in the repo.
* Corrected because a comment that still describes the old shape is worse than no comment — the
* next reader would have duplicated the rollup a third time on its authority. When a comment and
* the code disagree, the comment is the one to distrust.
*
* EVENT CONTRACT (for the shell that nests this element — see RESULT-regions-list-element.md):
* region-selected { slug, value, label, countryCount, count } — region heading clicked
* country-selected { slug, code, name, region: {slug,value,label}, count } — a country row clicked
* Both are emitted as a Vue `emit` AND as a native bubbling+composed CustomEvent on the host
* element (same double-emit pattern as people-groups-grid.vue's `groups-filtered`), so a plain
* host page hears it standalone and a Vue-nesting shell hears it as a prop-less DOM event.
*/
import { inject, ref, computed, onMounted, watch, getCurrentInstance } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { SHADOW_BOX_SIZING_RESET } from './src/shadowReset.js'
import { splitLastWord } from './src/useLastWordHighlight.js'
import { useRegionsI18n } from './src/useRegionsI18n.js'
// ar renders right-to-left. RTL_LOCALES is the shared set and :dir on our own root is the
// house pattern (doxa-simple-map.vue:18, :246, :1346) — an EMBEDDED element must not reach for
// document.documentElement.dir, which belongs to the host page.
import { RTL_LOCALES, localeFromUrl } from '@map/i18n/index.js'
import { useI18n } from 'vue-i18n'
import { useDocumentFont, BEBAS_NEUE_FACES, POPPINS_FACES, FIGTREE_FACES, OSWALD_FACES } from '@map/composables/useDocumentFont.js'
import { summarizeRegions, PEOPLE_GROUP_LIST_FIELDS } from '@map/utils/countries-meta.js'
// DX-life's spacing/type scales under THEIR token names. Prepended to this
// component's shadow CSS so every var() below resolves inside the shadow root.
import { DXLIFE_TOKENS } from './dxlife-tokens.js'
// THE COUNTRY-POSTER DROPDOWN (Driver 2026-09-24) — the THIRD sanctioned addition, after the
// SELECT VIEW tab strip and the no-selection chooser. Their wording: "a toggle or carrot added
// to the bottom of each region section that says view country posters".
//
// NOTHING HERE IS REBUILT. PosterWall is already the shared, fully parameterised grid that
// my-upg-100-list/poster-wall.vue and country-posters/country-poster-wall.vue both render, and
// it already owns the card layout, the "Show on map" header button, the Print Options footer
// and the client-side raster. This file supplies only data and a toggle — the same division of
// labour country-poster-wall.vue states in its own header ("this profile supplies only its
// data and taxonomy").
import PosterWall from '@map/components/poster/PosterWall.vue'
import posterData from '@map/data/country-posters.json'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { onBeforeUnmount } from 'vue'
const props = defineProps({
profileConfig: { type: String, default: '' },
// Same standalone-dep pattern as people-groups-grid.vue: the people-groups API host.
prayBaseUrl: { type: String, default: '' },
languageCode: { type: String, default: '' },
})
const emit = defineEmits(['region-selected', 'country-selected'])
// profile-config JSON can override prayBaseUrl / languageCode (single string attr).
// ★ THE CONFIG ARRIVES BY INJECT, NOT AS A PROP (measured 2026-09-23).
// The custom element this bundle registers IS ProfileLoader (index.js:41), which parses the
// profile-config ATTRIBUTE itself and hands the parsed object to its descendants via
// provide('profileConfig', config) (ProfileLoader.vue:195). So a profile component's own
// `profileConfig` STRING PROP is always empty, and every value read off it silently fell back
// to a default: prayBaseUrl, languageCode, instanceId and assetsBase alike. Nothing looked
// broken because the defaults happened to be right -- until assetsBase mattered, and the
// vendored Tabulator 404'd twice with no error while the table just never drew.
// Inject first, fall back to the prop so a direct <regions-dashboard profile-config='...'>
// outside ProfileLoader still works.
const injectedCfg = inject('profileConfig', null)
const cfg = computed(() => {
const inj = injectedCfg && (injectedCfg.value !== undefined ? injectedCfg.value : injectedCfg)
if (inj && typeof inj === 'object') return inj
if (!props.profileConfig) return {}
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
})
const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl || 'https://pray.doxa.life')
// The prayer API localises its LABELS from ?lang= — country names, region names, religion
// names. MEASURED: with locale=ar the UI translated but every country in the table stayed
// English, because this read only cfg.languageCode while the locale arrives as
// cfg.locale (ProfileLoader's contract, ProfileLoader.vue:156 accepts locale OR lang).
// Falling through to the same keys means translating the chrome also translates the data.
// ★ AND THE URL, LAST — the same fix region-dashboards.vue got, on the leaf that did not get it.
// I fixed the dashboard leaf and stopped, and the ENGLISH census could not see what was left:
// its ours-vs-theirs sets matched because both sides were in English. The ARABIC census found it
// on the first run — our h1 and subtitle rendered "المناطق" while all 41 country names in the
// table stayed "Angola", "Benin", "Cameroon". A shared /regions-list/?lang=ar link translated the
// chrome and then asked the prayer API in English, which is the defect this comment block already
// describes; it was only ever half fixed.
const languageCode = computed(() =>
props.languageCode || cfg.value.languageCode || cfg.value.locale || cfg.value.lang || localeFromUrl() || '')
// Where the shared asset tree is served from: the generator bakes it per page depth and the
// composite forwards it via INHERITED_PAGE_KEYS. Needed for the vendored @font-face path below.
const assetsBase = computed(() => cfg.value.assetsBase || './assets')
const { locale: i18nLocale } = useI18n()
const isRTL = computed(() => RTL_LOCALES.has(i18nLocale.value))
// ★ THEIR PAGE HEAD, WHICH THIS PROFILE DID NOT HAVE AT ALL.
// pages/regions/index.vue:47-58 opens with a centred .stack--xs of
// <h1 class="highlight" data-highlight-last>Regions</h1> and a .color-brand-lighter subtitle.
// We rendered only the region cards, so the standalone leaf page had no title and no strapline —
// invisible inside the composite, where the tab strip names the view, and plainly missing on
// /doxa-maps/regions-dashboard/regions-list/index.html, which is a page in its own right.
const { t, tf } = useRegionsI18n()
const headTitle = computed(() => splitLastWord(t('Regions')))
// ★ REGISTER BEBAS NEUE AT DOCUMENT LEVEL, not in the shadow root: @font-face is document-scoped
// and a face declared inside a shadow root is ignored. This profile registers it too, and not
// only tab 2, because its own leaf page (regions-list/index.html) is a standalone surface where
// tab 2 is not present — relying on a sibling would leave THIS page on the fallback face.
// Idempotent per document, so the composite still registers exactly one.
// ★ THE FONT BASE MUST BE ABSOLUTE, and this is the one place where that is not merely safer.
// @font-face is DOCUMENT-scoped: useDocumentFont injects a <style> into the HOST document, so a
// RELATIVE src resolves against the HOST PAGE, not against this bundle. On our own leaf that is
// invisible — the leaf's generated config says '../../assets' and the leaf really is two
// directories deep, so it resolves correctly. On a THIRD-PARTY PAGE it resolves against the
// partner's URL, the file is not there, and every face fails.
// MEASURED, which is the only reason I know: the cross-origin embed probe reported every
// FontFace in the partner document as status 'error' while the woff2 responses were 200 and the
// asset host was sending both access-control-allow-origin:* and content-type: font/woff2. Two
// wrong hypotheses died first — a missing CORS header (the server already sends it) and a check
// made before document.fonts.ready (fixed, still error). The URL was the answer.
// resolveAssetsBase exists for exactly this and its own header documents this bug class; the
// poster previews next door have used it all along. The font loader was the last consumer still
// taking the raw value.
const fontAssetsBase = resolveAssetsBase(cfg.value)
useDocumentFont('Bebas Neue', BEBAS_NEUE_FACES, fontAssetsBase)
// Poppins is their --font-family-base — the face of every line of BODY text on these
// pages. It was the larger of the two typography gaps and completely invisible to a
// computed-style diff, which read 68/68 while our body text was a system fallback 5.5%
// wider per glyph. Registered the same way and for the same reason as Bebas.
useDocumentFont('Poppins', POPPINS_FACES, fontAssetsBase)
// Figtree stands in for Brandon Grotesque, which is commercial and must not ship in a public
// giveaway (Driver, 2026-09-24). @font-face is DOCUMENT-scoped, so it registers here beside
// the other two rather than inside the shadow root that uses it.
useDocumentFont('Figtree', FIGTREE_FACES, fontAssetsBase)
// Oswald, for CYRILLIC HEADINGS ONLY. base/_globals.scss:35-44 switches every h1/h2/h3 to
// --font-family-cyrillic under [lang="ru-RU"], because BEBAS NEUE HAS NO CYRILLIC GLYPHS — their
// heading face cannot render Russian at all, so without the switch the heading leaves the family.
// We ship a `ru` locale and had neither the rule nor the font, so every Russian heading here was
// rendering in whatever condensed system face the browser picked.
useDocumentFont('Oswald', OSWALD_FACES, fontAssetsBase)
// ── state ───────────────────────────────────────────────────────────────────
const regions = ref([])
const loading = ref(true)
const loadFailed = ref(false)
// ── same field list the source page's PEOPLE_GROUP_LIST_FIELDS asks for ─────
// Their PEOPLE_GROUP_LIST_FIELDS, imported rather than restated — one list, one source.
const FIELDS = PEOPLE_GROUP_LIST_FIELDS
function formatNumber(n) {
// Theirs, RegionDetail.vue:16: n.toLocaleString(`${locale}-u-nu-latn`) — locale grouping
// separators with Latin digits. Hardcoding en-US made de/fr/es wrong (they group with '.'
// or a thin space). Same fix already applied in region-dashboards.vue.
const v = Number(n || 0)
try { return v.toLocaleString(`${i18nLocale.value || 'en'}-u-nu-latn`) } catch (_) { return v.toLocaleString('en-US') }
}
function summaryLine(region) {
// THEIRS, pages/regions/index.vue:29-31:
// t('%count% people groups in %countries% countries')
// .replace('%count%', formatNumber(region.count))
// .replace('%countries%', formatNumber(region.countryCount))
// This was a hardcoded English template, so /ru/regions rendered "315 people groups in 41
// countries" while theirs reads "315 групп людей в 41 странах". The ru string was ALREADY in
// library/i18n/locales/ru/regions.json — copied, never wired. Caught by reading the Russian
// screenshot, not by any assertion.
return tf('%count% people groups in %countries% countries', {
count: formatNumber(region.count),
countries: formatNumber(region.countryCount),
})
}
// ── emit (Vue + native composed CustomEvent, same pattern as people-groups-grid) ────────────
const inst = getCurrentInstance()
function hostEl() {
const root = inst?.vnode?.el || inst?.proxy?.$el
try { const rn = root && root.getRootNode && root.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {}
return root
}
function dispatch(name, detail) {
emit(name, detail)
const el = hostEl()
if (el && typeof el.dispatchEvent === 'function') {
el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }))
}
}
function selectRegion(region) {
dispatch('region-selected', {
slug: region.slug, value: region.value, label: region.label || 'Other',
countryCount: region.countryCount, count: region.count,
})
}
function selectCountry(country, region) {
dispatch('country-selected', {
slug: country.slug, code: country.code, name: country.name,
region: { slug: region.slug, value: region.value, label: region.label || 'Other' },
count: country.count,
})
}
// "See Africa dashboard" / "See Asia dashboard" — same wording pattern as DX-life's
// dashboardLink() in app/pages/regions/index.vue (t('See %region% dashboard')).
function dashboardLink(region) {
// THEIRS: t('See %region% dashboard') — ru renders "Открыть панель: %region%", which is not
// even the same word order, so a hardcoded English template could never be patched into it.
// The translation was already catalogued; only the wiring was missing.
return tf('See %region% dashboard', { region: region.label || t('Other') })
}
// ── data fetch (same live endpoint + fields as people-groups-grid.vue) ──────────────────────
async function loadRegions() {
const url = `${prayBaseUrl.value}/api/people-groups/list?fields=${FIELDS}&lang=${languageCode.value}`
loading.value = true
loadFailed.value = false
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(Array.isArray(data.posts) ? data.posts : [], injectedCfg)
regions.value = summarizeRegions(posts)
} catch (error) {
console.error('[regions-list] fetch error:', error)
loadFailed.value = true
regions.value = []
} finally {
loading.value = false
}
}
// ─── COUNTRY POSTERS, per region ─────────────────────────────────────────────────────────
// One wall per region, opened by the footer toggle. instanceId is suffixed with the region
// slug because PosterWall scopes its CustomEvent by instanceId so several embeds on one page
// never cross-talk (PosterWall.vue:40) — and here there genuinely are several.
const POSTER_EVENT = 'doxa-regions:show-country-on-map'
// Read off the injected config the same way every other value in this file is (see the note
// above cfg) — a bare default here would make two embeds on one page answer each other's
// poster events.
const instanceId = computed(() => cfg.value.instanceId || 'regions-list')
const posterAssetsBase = computed(() => resolveAssetsBase(cfg.value))
const openPosters = ref(new Set())
function togglePosters(region) {
const next = new Set(openPosters.value)
next.has(region.slug) ? next.delete(region.slug) : next.add(region.slug)
openPosters.value = next // a new Set, so the computed template actually re-renders
}
const postersOpen = (region) => openPosters.value.has(region.slug)
// The poster dataset is keyed by ISO-3 (`iso3`); the regions rollup keys by `code`. Same
// codes, so the join is direct.
const POSTER_BY_ISO3 = new Map((posterData.countries || []).map(c => [String(c.iso3 || '').toUpperCase(), c]))
// Every country in the region gets a card, in PosterWall's own shape. A country with no
// generated poster is NOT dropped — it is passed with a reason, which is what the wall's
// `unsupported` slot is for, so the grid never silently loses a country.
function postersFor(region) {
return (region.countries || []).map((c) => {
const rec = POSTER_BY_ISO3.get(String(c.code || '').toUpperCase())
if (rec) return rec
return {
id: `country-${String(c.code || '').toLowerCase()}`,
iso3: c.code,
label: c.name,
count: c.count,
unsupportedReason: t('No poster has been generated for this country yet.'),
}
})
}
// Their own not-yet reason travels WITH the record, so the number the wall prints can never
// drift from the data — country-poster-wall.vue:63 does exactly this. India is the only
// country in the dataset that carries one (855 people groups, no sheet); China is fine at 3x4.
const posterUnsupported = (c) => c.unsupportedReason || null
// One region's wall is already inside a region section, so it needs no second level of
// collapsing and no grouping: everything in it is that one region.
const posterGroupBy = () => 'countries'
// PosterWall's "Show on map" dispatches a window CustomEvent carrying listId (the poster id,
// e.g. "country-chn") or null when the user toggles the same card off. Mapping that id back to
// the country record and reusing selectCountry() is what makes the button open the dashboard
// tab for that country — the Driver's "click view on map button, which will open the country
// dashboard page for that country".
function onPosterShowOnMap(ev) {
const d = ev?.detail || {}
if (!String(d.instanceId || '').startsWith(instanceId.value)) return
if (!d.listId) return // a deselect is not a navigation
const iso3 = String(d.listId).replace(/^country-/, '').toUpperCase()
for (const region of regions.value) {
const hit = (region.countries || []).find(c => String(c.code || '').toUpperCase() === iso3)
if (hit) { selectCountry(hit, region); return }
}
}
// A LOCALE CHANGE MUST REFETCH, not just re-render. The region and country LABELS come from
// the API's ?lang=, not from the i18n catalogue — MEASURED on their live site, which serves
// "Азия"/"Африка"/"Азербайджан (3)" on /ru/regions against "Africa"/"Angola" on /regions. With
// no watcher this tab kept whatever labels the first fetch returned, so switching to Russian
// translated the chrome and left every region and country name in English. region-dashboards.vue
// already had this watcher (line 541); this tab was simply missed.
watch(languageCode, () => { loadRegions() })
onMounted(() => { if (typeof window !== 'undefined') window.addEventListener(POSTER_EVENT, onPosterShowOnMap) })
onBeforeUnmount(() => { if (typeof window !== 'undefined') window.removeEventListener(POSTER_EVENT, onPosterShowOnMap) })
onMounted(loadRegions)
// Design values are a DIRECT PORT of DX-life's live /regions page (root-dx-life/doxa-life —
// read-only, standing law), not our own judgement. DX-life's <html> sets
// `font-size: var(--font-size-base)` = 10px as its rem root (app/assets/styles/base/_globals.scss),
// so every rem token below is resolved against a 10px root, NOT the browser's 16px default — e.g.
// their --border-radius-xlg: 3.5rem is 35px, not 56px. Values were cross-checked two ways: hand-
// resolving the SCSS token files (tokens/semantic/_spacing.scss, _shapes.scss, _colors.scss,
// _typography.scss) AND reading getComputedStyle() live on https://doxa.life/regions — both agree.
// Colours/fonts/radii are an intentional exception to this repo's material-light chrome tokens —
// the Driver asked for an exact clone of DX-life's OWN design here, so the clone wins.
useShadowStyles(
// Their _reset.scss:3-8 FIRST -- see src/shadowReset.js.
SHADOW_BOX_SIZING_RESET +
DXLIFE_TOKENS +
':host{ display:block; width:100%; height:100%; min-height:320px; }' +
'.rl-root{ position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;' +
// --dx-surface = --color-surface-default ($color-beige-50); --dx-elevated = --color-surface-white
// --dx-text = --color-text-on-default ($color-green-600 #2a332a) -- body copy on the light page bg
// --dx-header-bg = --color-surface-brand ($color-green-500 #3b463d) -- the card header band
// --dx-header-text = --color-text-on-brand (#fff); --dx-link = --color-brand-light ($color-green-400 #4e594f)
// --dx-muted = --color-brand-lighter (colors.scss redeclares this twice; 2nd wins: $color-green-300 #6d766e)
// --dx-line = --color-brand-secondary-light ($color-beige-200 #d1d4cd)
' --dx-surface:#f3f3f1; --dx-elevated:#fff; --dx-text:#2a332a; --dx-header-bg:#3b463d;' +
' --dx-header-text:#fff; --dx-link:#4e594f; --dx-muted:#6d766e; --dx-line:#d1d4cd;' +
' --dx-radius:35px;' +
' background:var(--dx-surface); color:var(--dx-text);' +
' font:var(--font-size-md)/1.5 Poppins,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,sans-serif; }' +
// container-type:inline-size makes this pane the query container for the cqw units below.
// DX-life sizes those with 4vw (the VIEWPORT), which is right for a full-page route and wrong
// for us: this element is also one pane of the regions-mfe composite, where the viewport is not
// the width available. cqw honours their DECISION (scale with the width you actually have)
// rather than transcribing a unit that only works in their layout. Same primitive already used
// in research-dashboard.vue:284. Safe here because nothing in this element is position:fixed --
// inline-size containment is what made a fixed scrim pane-relative in check:scrim-viewport.
// Side padding is deliberately 0: the .container formula below supplies the gutter, as theirs does.
// ★ scrollbar-gutter: stable — ported from their base/_globals.scss html rule, and it is
// LOAD-BEARING for layout, not cosmetic. MEASURED at 390px: their container is 353.5px
// and ours was 367px, because their html reserves the scrollbar gutter always and our
// scroller did not. 13.5px, invisible at 1440 where both cap at 1200 — which is exactly
// why a desktop-only diff read 68/68 while every mobile width was wrong.
// Worse, it CASCADED: at 768 our container came out 707.2px and theirs 693.7px, and
// their .switcher flips at 700px of container — so their three goal cards stacked and
// ours stayed three-across. One missing property, a different layout.
// We cannot set this on the host document from inside a shadow root, and should not;
// this element's own scroller is the box every child measures against.
'.rl-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; scrollbar-gutter:stable; -webkit-overflow-scrolling:touch;' +
' padding:20px 0; display:flex; flex-direction:column; gap:var(--spacing-lg);' +
' container-type:inline-size; }' +
// ★ THE DESKTOP MAX-WIDTH, ported verbatim from DX-life's .container
// (app/assets/styles/composition/_container.scss), which BOTH /regions and /regions/<slug> use:
// --container-max-width:1200px; --screen-min-width:320px; --min-margin:8px;
// width: calc(90% + (10/100 * 320px) - (2 * 8px)) -> calc(90% + 16px)
// max-width: 1200px; margin-left/right: auto
// So it is FLUID at 90%+16px until the viewport reaches 1315.625px, then pinned at 1200px centred.
// Our cards previously stretched to the full pane at every width -- there was no cap at all.
// box-sizing:border-box so .rl-status's own 28px padding stays inside the cap.
'.rl-scroll > *{ width:calc(90% + 16px); max-width:1200px;' +
' margin-left:auto; margin-right:auto; }' +
// Their head block: .stack--xs (10px) gap, centred. h1 is _globals' clamp(--font-size-3xl,
// 7vw, --font-size-5xl) and the subtitle is --font-size-md in --color-brand-lighter.
// cqw not vw, for the same composite-pane reason as every other clamp here.
// ★ THEIR REDUCED-MOTION RESET, which a shadow boundary had been swallowing.
// base/_reset.scss:66-79 is `@media (prefers-reduced-motion: reduce) { *, *::before, *::after {
// animation-duration:.01ms!important; animation-iteration-count:1!important;
// transition-duration:.01ms!important; scroll-behavior:auto!important } }` -- a DOCUMENT-level
// universal selector, so it styles nothing inside a shadow root. The same boundary swallowed
// their box-sizing reset.
// It matters because we ported their animation faithfully: their RegionDetail.vue:209 and our
// .rd-seg / .rd-bar__slider both carry `transition: width 1s ease-out`. MEASURED with
// emulateMedia on the segment that actually carries it -- theirs goes 1s -> 1e-05s under reduce,
// ours stayed 1s -> 1s. So a vestibular visitor got a full one-second sliding bar from us that
// their own site suppresses: a clone difference and an accessibility regression in one.
// Their `html:focus-within{scroll-behavior:auto}` half is deliberately NOT ported -- a shadow
// root cannot style the host document's html element, and the leaf page owns that.
'@media (prefers-reduced-motion: reduce){' +
' .rl-root, .rl-root *, .rl-root *::before, .rl-root *::after{' +
' animation-duration:0.01ms !important; animation-iteration-count:1 !important;' +
' transition-duration:0.01ms !important; scroll-behavior:auto !important; } }' +
'.rl-head{ display:flex; flex-direction:column; gap:10px; text-align:center; }' +
// NO text-transform. MEASURED on their live h1, both locales: text-transform is `none`
// (/regions and /ru/regions alike). This rule used to force uppercase, which is INVISIBLE in
// English because Bebas Neue is an all-caps face — and wrong the moment the text is not Latin:
// Bebas carries no Cyrillic, so /ru falls back to a normal system face and their heading reads
// "Регионы" while ours read "РЕГИОНЫ". The same conclusion is already written out for
// .region-card__title below ("DX-life applies NO transform on top of the font"); this rule was
// simply missed, and no check caught it because the vs-live property diff covers the DASHBOARD
// tab's .rd-title, never this one. _probe-list-vs-live.mjs now covers it.
'.rl-title{ margin:0; font-size:clamp(var(--font-size-3xl), 7cqw, var(--font-size-5xl)); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; }' +
'.rl-title__last{ color:#92b195; }' +
'.rl-summary{ margin:0; color:var(--dx-muted); font-size:var(--font-size-md); }' +
'.rl-status{ padding:28px; text-align:center; color:var(--dx-muted); font-size:var(--font-size-md); }' +
'.rl-status.rl-failed{ font-weight:600; }' +
// .region-card: DX-life's --border-radius-xlg (35px), overflow hidden, white body, no border --
// the dark header band supplies its own edge, unlike our old grey-outlined card.
'.region-card{ flex:0 0 auto; border-radius:var(--dx-radius); overflow:hidden; background:var(--dx-elevated); }' +
// .region-card__header: padding = --spacing-md / clamp(--spacing-md,4vw,--spacing-2xl) -- the clamp
// is KEPT as a clamp (var(--spacing-md) / var(--spacing-2xl) are the resolved ends), not frozen at its desktop maximum.
// gap = --spacing-2xs (7.5px), on --color-surface-brand.
'.region-card__header{ display:flex; flex-direction:column; gap:var(--spacing-2xs);' +
' padding:var(--spacing-md) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl));' +
' background:var(--dx-header-bg); color:var(--dx-header-text); }' +
// .region-card__title: font-size clamp(--font-size-xl,4vw,--font-size-3xl) = clamp(var(--font-size-xl),_,var(--font-size-3xl)).
// A phone gets the var(--font-size-xl) FLOOR, as theirs does; it only reaches var(--font-size-3xl) past ~1192px of width.
// DX-life's heading font (Bebas Neue -- a bold condensed display face), regular weight (the
// face itself carries the boldness), all-caps to match the source screenshot exactly.
// Bebas Neue itself is DX-life's own self-hosted font file (doxa.life/assets/fonts/BebasNeue/),
// not served with CORS -- cross-origin @font-face silently fails without it, so it cannot be
// loaded here without shipping a font asset this repo doesn't own. Approximated with the
// standard condensed-display-face fallback stack (bold weight + font-stretch:condensed + tight
// tracking), since Bebas Neue's glyphs read as heavy/compressed even at its nominal "400" weight.
// getComputedStyle() on the live card title reads weight 400 / line-height 1.0 (47.68px on a
// 47.69px font-size) / letter-spacing normal / text-transform none -- Bebas Neue's own glyphs
// are what read as bold+condensed+caps-like, DX-life applies NO transform on top of the font.
// Our fallback stack has no true Bebas-Neue substitute, so font-stretch:condensed stands in for
// the face's natural narrowness, but weight/leading/tracking/case now match the measured values
// exactly rather than compounding extra "boldness" the source doesn't have.
'.region-card__title{ margin:0; font-size:clamp(var(--font-size-xl), 4cqw, var(--font-size-3xl)); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; letter-spacing:normal; }' +
// Their link is an <a class="region-card__link | with-icon">, so it carries their `a` rule's
// font-weight: var(--font-weight-medium) = 500 (_globals.scss:132). Ours is a <button>
// because this element has no router, and `font:inherit` gave it 400 — MEASURED against
// their live /regions. Same class of miss as the dashboard back-link.
// The icon spacing is theirs too: .with-icon is inline-flex with align-items:baseline and NO
// gap (composition/_icon.scss:12), and the 1rem separation comes from
// `.with-icon .icon.right { margin-inline-start: 1rem }` — 10px at their 10px root. We had
// the same 10px via `gap`, which looks identical but means their rule would not apply, so
// the mechanism is now theirs: margin on the icon, gap left alone.
// ★ text-align:START, not left. Theirs is a NuxtLink, an inline <a> that shrink-wraps to its
// text; ours must be a <button> because this element changes the embed's subject in place instead
// of navigating a route. A button's UA default is text-align:center, so the port overrode it -- but
// it overrode it with the PHYSICAL `left`, which is identical to `start` in English and its
// opposite in Arabic. MEASURED on live /ar/regions vs ours at dir=rtl: same 246.9px row and the
// same 102.7px of text, their label flush to the row's RIGHT edge (gap 0px, the RTL reading start)
// and ours flush LEFT -- every label stranded 144.2px on the wrong side. Invisible in English,
// which is exactly why it survived a 37-property card diff and an 11-property bullet diff.
'.region-card__link{ appearance:none; background:none; border:0; padding:0; margin:0; text-align:start;' +
' cursor:pointer; color:inherit; font:inherit; font-weight:500;' +
' display:inline-flex; align-items:baseline; }' +
'.region-card__link:hover{ color:#92b195; }' +
'.region-card__link:focus-visible{ outline:2px solid #fff; outline-offset:2px; }' +
// .region-card__icon: DX-life's own <svg class="icon | rotate-90 right"><use href="arrow-
// chevron.svg#chevron-up"></use></svg> -- a REAL chevron path (viewBox 0 0 489.67 289.877),
// not a text glyph. Ported verbatim (fill:currentColor tracks the surrounding text colour,
// same as their `.icon{ fill:currentColor }` utility) and rotated 90deg to point right, same
// as their `rotate-90` class. getComputedStyle measured it at 33.38px square on the live page.
// ★ 1rem WAS THE ONE UNIT IN THIS PORT THAT INHERITS THE HOST PAGE'S ROOT, and it was wrong on
// our own leaf, not only in an embed. `rem` resolves against the DOCUMENT root wherever the
// element lives — which is the whole reason dxlife-tokens.js resolves their scale to px — and
// their root is 10px (html{font-size: var(--font-size-base)}, $font-size-base: 10px). Ours
// inherits whatever the host sets: 16px by default, so this gap rendered 16px against their
// 10px, and on a host with the common html{font-size:62.5%} reset it would render 10px by
// accident. Pinned to the value their root produces, like every other token here.
// No diff caught it: the list diff's `link` spec reads colour, decoration, weight, display and
// alignItems — no margin — so the icon's own spacing was never a measured property. It is now.
// (Credit where due: the class of bug came to me as a flock relay from another agent's sweep.)
'.region-card__icon{ width:0.7em; height:0.7em; flex:none; fill:currentColor; transform:rotate(90deg);' +
' margin-inline-start:10px; }' +
// .region-card__summary: --font-size-md (var(--font-size-md)), 0.85 opacity, on the header band.
'.region-card__summary{ font-size:var(--font-size-md); opacity:0.85; margin:0; }' +
// ★ CYRILLIC HEADINGS SWITCH FACE, as theirs do. base/_globals.scss:35-44:
// h1,h2,h3 { [lang="ru-RU"] & { font-family: var(--font-family-cyrillic); letter-spacing: -0.02em } }
// and --font-family-cyrillic is Oswald. The switch is not decoration: BEBAS NEUE HAS NO CYRILLIC
// GLYPHS, so a Russian heading in it leaves the family entirely and lands on whatever condensed
// system face the browser picks. We ship a `ru` locale and had neither the rule nor the font.
// ONLY the headings, exactly as theirs — their .region-stat__value is a <p> and keeps Bebas even in
// Russian, which is their behaviour and so is ours.
// BOTH TAG FORMS, deliberately: their site tags the document ru-RU while our i18n locale is `ru`,
// and a host page embedding this element may set either. A selector that matched only one would
// work on their site and fail on ours, or the reverse, depending on who set the attribute.
'[lang="ru"] .rl-title, [lang="ru-RU"] .rl-title,' +
'[lang="ru"] .region-card__title, [lang="ru-RU"] .region-card__title{ font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' letter-spacing:-0.02em; }' +
// .region-card__body: padding --spacing-lg / clamp(...) / --spacing-xl -> var(--spacing-lg) / clamp / var(--spacing-xl)
'.region-card__body{ padding:var(--spacing-lg) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-xl); }' +
// .countries-grid: DX-life's track is minmax(220px,1fr). Gap --spacing-xs / --spacing-xl
// (10px / var(--spacing-xl)). getComputedStyle on the live grid shows padding-left:35.16px -- that's the
// page's global `ul` reset (bullets kept, not list-style:none), so the whole grid is indented
// one list-item's worth even though DX-life's countries-grid a rule doesn't set it directly.
// Reproduced with list-style:none + the same 35.16px indent kept explicitly so a real list
// bullet has somewhere to sit without stealing width from the text column.
// padding-INLINE-start, as theirs is (1.8em). The physical `padding-left:35.2px` it replaces
// put the indent on the wrong side in Arabic, where the bullets hang off the right edge.
'.countries-grid{ list-style:none; margin:0; padding-inline-start:1.8em; display:grid;' +
' grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px var(--spacing-xl); }' +
// Plain inline text (not a flex row) so multi-line country names wrap the same way DX-life's
// <NuxtLink> text does -- a flex row with align-items:baseline pinned the bullet to the first
// line only and stranded the (count) span beside the second line on wrap. font-weight:500
// matches getComputedStyle() on the live country links (theirs is medium, not regular).
// Their country entries are bare <NuxtLink>s inside .countries-grid li — no class of their
// own — so they take their `a` rule: color var(--color-brand-light) = $color-green-400 =
// #4e594f, font-weight medium. MEASURED on their live /regions: rgb(78, 89, 79). Ours used
// var(--dx-text), which resolves to rgb(42, 51, 42) — the body ink, not their link ink.
'.country-link{ appearance:none; background:none; border:0; padding:2px 0; margin:0;' +
' text-align:start; cursor:pointer; color:#4e594f; font:inherit; font-size:var(--font-size-md);' +
' font-weight:500; width:100%; position:relative; }' +
// inset-inline-start, NOT left: the bullet hangs off the text's starting edge, which is the
// RIGHT edge in Arabic. With `left:-16px` it stayed on the physical left in RTL — measured
// at locale=ar, computed right was 256.938px instead of the -16px near-edge overhang.
// ★ THEIR BULLET, COPIED — not a dot of my own. base/_globals.scss:117-131 paints the list
// marker with a 16x16 data-URI SVG circle in #92b195 sized to 1.5em, hung off the text's start
// edge by margin-inline-start:-1.3em (-2em in RTL), top .3em, line-height .9. Their
// .countries-grid comment is explicit that keeping it is deliberate ("Keeps the site's list
// bullets (global ul padding + ::before dot)").
// I had re-created it as a 6px border-radius:50% square, and MEASURED against live it was wrong
// in 9 of 11 properties: 6px vs 29.2969px box, solid fill vs a transparent box carrying the
// image, top 11.72px vs 5.86px, and positioned with inset-inline-start:-16px instead of their
// negative margin. Not a near-miss — a different bullet.
// The rule is a double-quoted JS string because the data URI contains single quotes.
".country-link::before{ content:''; position:absolute; top:0.3em; width:1.5em; height:1.5em;" +
" line-height:0.9; margin-inline-start:-1.3em; background-repeat:no-repeat;" +
" background-image:url(\"data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='%2392b195' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='8' cy='8' r='7' stroke='%2392b195' stroke-width='1.5'/%3E%3C/svg%3E\"); }" +
// Their RTL variant. It is a separate rule here rather than a nested [dir="rtl"] & because this
// CSS lives in a shadow root, where a [dir] selector on an outer ancestor does not reach in.
'[dir="rtl"] .country-link::before{ margin-inline-start:-2em; }' +
'.country-link:hover{ text-decoration:underline; text-decoration-color:#92b195; }' +
'.country-link:focus-visible{ outline:2px solid #92b195; outline-offset:2px; border-radius:4px; }' +
'.country-link__count{ color:var(--dx-muted); }' +
// .region-card__footer: DX-life's exact rule -- flex, flex-end, margin-top --spacing-lg (var(--spacing-lg)),
// NO margin-bottom. This element is a <p>; its user-agent default margin-block-end (~var(--font-size-md) at
// this font-size) was leaking through because only margin-top was ever set -- pin margin:0
// first, then apply margin-top, so the bottom UA default can't survive the cascade.
// The toggle row and the wall it opens. The caret rotates rather than swapping glyphs,
// which is what makes open/closed legible at a glance.
'.region-card__posters-toggle{ margin-top:0; }' +
'.region-card__icon--open{ transform:rotate(180deg); }' +
// RTL MIRRORING, ported from their utilities/_shapes.scss:
// .rotate-90 { transform:rotate(90deg); [dir="rtl"] & { transform:rotate(-90deg); } }
// Their .rotate-180 has NO rtl variant, and that distinction is the whole rule: a chevron that
// means "forward" must mirror, a disclosure caret that means "open/closed" must not. MEASURED
// on live doxa.life: .region-card__link svg is matrix(0,1,-1,0,0,0) at /regions and
// matrix(0,-1,1,0,0,0) at /ar/regions — ours stayed at +90deg in Arabic, so the chevron sat on
// the correct side pointing the WRONG WAY. Found by looking at the Arabic screenshot.
// The selector works inside our shadow root because .rl-root carries :dir itself.
'[dir="rtl"] .region-card__icon{ transform:rotate(-90deg); }' +
// ...and this one is load-bearing: [dir="rtl"] .region-card__icon is specificity 0-2-0, which
// would otherwise beat the 0-1-0 --open modifier and freeze the poster caret in Arabic.
'[dir="rtl"] .region-card__icon--open{ transform:rotate(180deg); }' +
'.region-card__posters{ margin-top:13.3px; }' +
'.region-card__footer{ display:flex; justify-content:flex-end; margin:0; margin-top:var(--spacing-lg);' +
' padding:0 clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-lg); }' +
'.region-card__footer-link{ appearance:none; background:none; border:0; padding:0; margin:0;' +
' cursor:pointer; font:inherit; font-size:var(--font-size-md); font-weight:500; color:var(--dx-link);' +
' display:inline-flex; align-items:baseline; gap:10px; text-decoration:underline; text-decoration-thickness:2px; }' +
'.region-card__footer-link:hover{ color:var(--dx-header-bg); }' +
'.region-card__footer-link:focus-visible{ outline:2px solid var(--dx-link); outline-offset:2px; border-radius:4px; }',
'regions-list'
)
</script>
<template>
<!-- :lang is here for the SAME REASON :dir is: a [lang=] selector in this shadow root cannot
see the host document's attribute, so the switch their global sheet performs on
[lang="ru-RU"] can only fire if the attribute is INSIDE the root. It is also simply
correct HTML — screen readers and hyphenation read it. -->
<div class="rl-root" :lang="i18nLocale" :dir="isRTL ? 'rtl' : 'ltr'">
<!-- THEIR ICON, DELIVERED THE WAY THEIR MARKUP DELIVERS IT. Theirs is
<svg class="icon | rotate-90 right"><use href="/assets/icons/arrow-chevron.svg#chevron-up"/></svg>
— one <use> reference, not a path repeated per call site.
MEASURED BEFORE CHOOSING, because I assumed the platform forced the inline copy and it
does not: an external <use href="file.svg#id"> renders in Chromium, renders INSIDE a
shadow root, and a local <symbol> + <use href="#id"> renders too — bbox 410x234 in all
three cases.
WHAT IS ACTUALLY FORCED IS THE EXTERNAL HREF, not the mechanism. A dropped bundle
resolves assets against whatever tree it sits in, and generate-tree stages an allowlist
with no icons/ dir, so an external href would 404 SILENTLY and the chevron would simply
vanish. Same reason worldmap.svg arrives through an imagesBase prop instead of their bare
`const imagesUrl = '/assets/images'`.
So: their 454-byte file's path verbatim, as a <symbol> declared ONCE, referenced by
<use href="#chevron-up"> exactly as their markup does. No external fetch, and the path is
no longer copied three times. -->
<svg width="0" height="0" aria-hidden="true" focusable="false" style="position:absolute">
<symbol id="chevron-up" viewBox="0 0 489.67 289.877">
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-11.311,11.311-11.311,29.65,0,40.961h0c11.311,11.311,29.65,11.311,40.961,0L242.667,97.248l155.39,155.39c11.311,11.311,29.65,11.311,40.961,0h0c11.311-11.311,11.311-29.65,0-40.961Z" />
</symbol>
</svg>
<div class="rl-scroll">
<!-- pages/regions/index.vue:47-58 — their centred head: the title with its last word
in brand primary, and the strapline underneath. -->
<div class="rl-head">
<h1 class="rl-title">{{ headTitle.head ? headTitle.head + ' ' : '' }}<span class="rl-title__last">{{ headTitle.last }}</span></h1>
<p class="rl-summary">{{ t('Find unreached people groups by region and country.') }}</p>
</div>
<!-- THEIR string, not a new one: 'Loading results...' is in their common.json and so is
already translated in all eight locales. Their /regions is server-rendered and has no
loading state of its own, but reusing their wording beats inventing English here. -->
<div v-if="loading" class="rl-status">{{ t('Loading results...') }}</div>
<div v-else-if="loadFailed" class="rl-status rl-failed" role="status">{{ t('Region data could not be loaded. This is a connection problem, not a filter.') }}</div>
<div v-else-if="!regions.length" class="rl-status">{{ t('No regions found.') }}</div>
<template v-else>
<div
v-for="region in regions"
:key="region.slug"
class="region-card"
>
<div class="region-card__header">
<h2 class="region-card__title">
<button
type="button"
class="region-card__link"
@click="selectRegion(region)"
>
{{ region.label || 'Other' }}
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
<use href="#chevron-up" />
</svg>
</button>
</h2>
<p class="region-card__summary">{{ summaryLine(region) }}</p>
</div>
<div class="region-card__body">
<ul class="countries-grid">
<li v-for="c in region.countries" :key="c.slug">
<button
type="button"
class="country-link"
@click="selectCountry(c, region)"
>
{{ c.name }} <span class="country-link__count">({{ c.count }})</span>
</button>
</li>
</ul>
</div>
<p class="region-card__footer">
<button
type="button"
class="region-card__footer-link"
@click="selectRegion(region)"
>
{{ dashboardLink(region) }}
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
<use href="#chevron-up" />
</svg>
</button>
</p>
<!-- THE POSTER TOGGLE — Driver 2026-09-24: "a toggle or carrot added to the bottom
of the region section that says view country posters, which will let you view
country posters for each of the countries in the regions". The caret is the
same SVG the header and footer links already use, rotated when open, so the
control reads as part of this card rather than bolted on. aria-expanded and
aria-controls are what make it a real disclosure for a screen reader. -->
<p class="region-card__footer region-card__posters-toggle">
<button
type="button"
class="region-card__footer-link"
:aria-expanded="postersOpen(region) ? 'true' : 'false'"
:aria-controls="`posters-${region.slug}`"
@click="togglePosters(region)"
>
{{ postersOpen(region) ? t('Hide country posters') : t('View country posters') }}
<svg
class="region-card__icon"
:class="{ 'region-card__icon--open': postersOpen(region) }"
viewBox="0 0 489.67 289.877"
aria-hidden="true"
>
<use href="#chevron-up" />
</svg>
</button>
</p>
<!-- The wall itself: the SHARED component, given only this region's countries. No
second level of collapsing (we are already inside one region) and no grouping,
hence posterGroupBy returning ''. -->
<div v-if="postersOpen(region)" :id="`posters-${region.slug}`" class="region-card__posters">
<!-- poster-action="print" — PRINT IN PLACE, not download (Driver card 2026-09-24).
PosterWall's posterAction prop defaults to 'download'; UPG-100 passes "print"
and the print UX here must be identical. deliverPoster() branches on that one
prop and routes BOTH the shipped-.webp and the browser-rasterised path through
openPrintFrame, so this is configuration — not a second implementation. -->
<!-- ★ TWO PER ROW, AND A KNOB. The Driver, verbatim: "i need two posters per row on
the regiosn posters and witha setitn got icnreat eh number of poswers per row".
The wall's own default is an auto-fill grid at minmax(210px,1fr), which lays out
five across a region card; two is a deliberate, larger card, and the control lets
a reader put it back.
Both props are OPT-IN on the shared component, so country-posters and
my-upg-100-list keep the auto-fill they have always had.
The comment sits ABOVE the tag because an HTML comment inside an attribute list is
a parse error — which is how the first attempt at this failed. -->
<PosterWall
:items="postersFor(region)"
:group-by="posterGroupBy"
:instance-id="`${instanceId}-${region.slug}`"
:assets-base="posterAssetsBase"
:event-name="POSTER_EVENT"
:heading="''"
:intro="''"
unit="UPG"
item-noun="country"
item-noun-plural="countries"
:collapsible="false"
:embedded="true"
:section-heads="false"
:columns="2"
:columns-control="true"
:unsupported="posterUnsupported"
download-prefix="doxa-country"
poster-action="print"
prayer-list-soon-message="Coming soon — a prayer list will be generated for each country, naming every unengaged people group within it."
/>
</div>
</div>
</template>
</div>
</div>
</template>