Picking a region from the all-regions table, or a country from a region, kept the old scroll height, so the new page opened at the bottom; the dashboard now scrolls its own container to the top when the subject changes (switching to the list and back changes no subject and keeps its place). The region and country maps take the all-regions map's square shape under 768px; the shared map component keeps its portrait default for the country page.
1318 lines
86 KiB
Vue
1318 lines
86 KiB
Vue
<script setup>
|
|
/**
|
|
* region-dashboards.vue — Tab 2 ("Region Dashboards") of the regions-dashboard bundle.
|
|
*
|
|
* @description The dashboard half of the 2-tab Regions element: given a region (or a
|
|
* single country inside one), it summarises that selection — headline stats plus a comparison
|
|
* of its countries — using the SAME live people-groups endpoint and the SAME rollup fields as
|
|
* regions-list.vue (tab 1), so the two tabs can never disagree about a number.
|
|
*
|
|
* HOW IT LEARNS WHAT TO SHOW. This element is deliberately not wired to tab 1 directly. It takes
|
|
* its selection from its own `profile-config` (keys `regionSlug` / `countrySlug`) and ALSO listens
|
|
* for a scoped window event, 'doxa-regions-dashboard:select'. The composite (regions-mfe.vue)
|
|
* nests both tabs as sibling custom elements and forwards tab 1's `region-selected` /
|
|
* `country-selected` onto that event, scoped by instanceId — the same cross-pane seam
|
|
* upg-100-mfe.vue uses for the poster wall's 'doxa-upg-100-list:show-on-map'. Keeping the seam an
|
|
* EVENT (not a prop) is what lets each tab keep its own ProfileLoader/Pinia and still stay in sync.
|
|
*
|
|
* WITH NO SELECTION it shows every region compared against each other, so the tab is meaningful
|
|
* standalone (and on first paint, before anything has been clicked) rather than an empty state.
|
|
*
|
|
* SOURCE for the grouping logic: library/utils/countries-meta.js — the port of DX-life's
|
|
* config/countries-meta.ts. BOTH tabs import summarizeRegions and PEOPLE_GROUP_LIST_FIELDS from
|
|
* there, so "kept field-for-field identical" is no longer a discipline anyone has to maintain: it
|
|
* is the same function. This paragraph previously said the logic was copied from regions-list.vue
|
|
* rather than imported, which was true before the port and false afterwards.
|
|
*/
|
|
import { inject, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
|
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.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 { resolveAssetsBase } from '@map/utils/embedBase.js'
|
|
import { useDocumentFont, BEBAS_NEUE_FACES, POPPINS_FACES, FIGTREE_FACES, OSWALD_FACES } from '@map/composables/useDocumentFont.js'
|
|
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
|
|
import UupgsList from './src/UupgsList.vue'
|
|
// Their strings, via their own English keys (see @map/i18n/useRegionsI18n.js). The goal sentences,
|
|
// the stat labels and the table headers are all t() calls on their page; hardcoding the
|
|
// English was the last thing keeping this clone monolingual.
|
|
import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js'
|
|
import { splitLastWord } from './src/useLastWordHighlight.js'
|
|
// Region colours come from the shared strategy (check:one-palette), and REGION_ORDER is the
|
|
// order its CVD separation was validated in — the legend must render in it for that to hold.
|
|
import { PALETTE as REGION_PALETTE, WAGF_REGIONS, REGION_ORDER } from '@map/colors/wagf-region.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'
|
|
import RegionCountryMap from '@map/components/maps/RegionCountryMap.vue'
|
|
// The ONE print mechanism (five copies of this busy state exist in the repo; this is the shared one).
|
|
import { printWhenReady } from '@map/composables/printInPlace.js'
|
|
// REGLIB batch 2: the stat card, the three goal cards and their bars are library components now.
|
|
// They carry their own shadow CSS (useShadowStyles) and the card surface they share, so the rules
|
|
// for .rd-card / .rd-stat* / .rd-goal* / .rd-big* / .rd-bar* / .rd-legend are no longer below.
|
|
import StatCard from '@map/components/regions/StatCard.vue'
|
|
import GoalCard from '@map/components/regions/GoalCard.vue'
|
|
import ProgressBar from '@map/components/charts/ProgressBar.vue'
|
|
import BackToTopButton from '@map/components/BackToTopButton.vue'
|
|
import { SHADOW_BOX_SIZING_RESET } from './src/shadowReset.js'
|
|
import { summarizeRegions, emptyStats, toNumber, PEOPLE_GROUP_LIST_FIELDS } from '@map/utils/countries-meta.js'
|
|
import { peopleGroupPoints, PEOPLE_GROUP_POINT_FIELDS } from '@map/utils/peopleGroupPoints.js'
|
|
import { cyrillicHeading } from '@map/styles/portedGlobals.js'
|
|
|
|
const props = defineProps({
|
|
profileConfig: { type: String, default: '' },
|
|
prayBaseUrl: { type: String, default: '' },
|
|
languageCode: { type: String, default: '' },
|
|
})
|
|
|
|
// ★ 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')
|
|
// Their popup's second button. RegionDetail passes CountryMap `:research-url="localePath('/research') + '/'"`;
|
|
// we have no router, so it comes from profile-config with their public site as the default —
|
|
// the same ladder and the same default UupgsList.vue:74 already uses.
|
|
const researchUrl = computed(() => cfg.value.researchUrl || 'https://doxa.life/research/')
|
|
// Where a country NAME links, as distinct from where a people group's profile lives. DX-life sends
|
|
// it to its own /regions/<slug> page, so a host embedding us keeps that under its own control.
|
|
const countryUrl = computed(() => cfg.value.countryUrl || 'https://doxa.life/regions/')
|
|
// 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 prayer API localises its LABELS from its own lang parameter, so the
|
|
// region and country NAMES follow whatever this resolves to. Reading only props and profile-config
|
|
// meant a SHARED LINK — /region-dashboards/?lang=ar, the URL a person actually sends someone —
|
|
// translated the chrome through detectLocale() and then asked the API in English. MEASURED: 597
|
|
// Arabic chrome characters via profile-config against 309 via ?lang=ar on this leaf, and the whole
|
|
// gap was the seven region names in the chooser legend plus the seven in the table.
|
|
// localeFromUrl() is the library's own reader, imported rather than re-implemented, so the data
|
|
// call and the chrome can never disagree about what the URL asked for.
|
|
const languageCode = computed(() =>
|
|
props.languageCode || cfg.value.languageCode || cfg.value.locale || cfg.value.lang || localeFromUrl() || '')
|
|
const instanceId = computed(() => cfg.value.instanceId || '')
|
|
// THE CAP-4 ASSET-PATH LESSON (BUG-UPG100MFE-ASSET-PATH, and it bit again here): DataTable
|
|
// injects the vendored Tabulator from a path RELATIVE to the document. Nested inside the
|
|
// regions-mfe composite the document is .../regions-mfe/index.html, so './assets' resolved to
|
|
// .../regions-mfe/assets/vendor/tabulator/* and 404'd twice with no visible error -- the table
|
|
// simply never drew. The generator bakes the right base per page depth and regions-mfe.vue
|
|
// already forwards it in INHERITED_PAGE_KEYS; this reads it instead of trusting the default.
|
|
const assetsBase = computed(() => cfg.value.assetsBase || './assets')
|
|
|
|
// ★ 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. Their headings ARE
|
|
// this font (tokens/semantic/_typography.scss:5) and it could not be loaded from their
|
|
// origin — no CORS headers — so it is vendored under public/assets/fonts/bebas-neue (OFL,
|
|
// provenance in LICENSE.txt beside it). Loading it deleted an uppercase imitation and two
|
|
// stepped-down font-sizes that only existed because the fallback face was wider.
|
|
// ★ 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. @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 { t, tf , locale } = useRegionsI18n()
|
|
// syncTitle() shadows `t` with the subject title, so it needs a second handle on the
|
|
// translator. Same function, different name — not a second i18n instance.
|
|
const t2 = t
|
|
// their <h1 class="highlight" data-highlight-last> — the final word in brand primary
|
|
const titleParts = computed(() => splitLastWord(subject.value?.title || ''))
|
|
const isRTL = computed(() => RTL_LOCALES.has(locale.value))
|
|
|
|
const regions = ref([])
|
|
// The rollup aggregates records away, and the map needs them one by one. Kept flat and
|
|
// pre-parsed once here rather than re-fetched inside the map child, as DX-life's CountryMap
|
|
// does -- one request per element, and the pins provably describe the same rows as the stats.
|
|
const points = ref([])
|
|
// The scroll container the back-to-top arrow drives. It is .rd-scroll rather than the window:
|
|
// this plugin renders inside a shadow root in someone else's page, so the page's scroll is not
|
|
// ours to move and may not even be the one the reader is using.
|
|
const scrollEl = ref(null)
|
|
const loading = ref(true)
|
|
|
|
// ★ PRINT — the half the @media print rules could not do. The reported complaint was
|
|
// "no loading indicator on a click": the reader presses Print, the dialog takes a second to
|
|
// rise, and nothing on the page says anything is happening. Worse on this page than most, because
|
|
// what the dialog is waiting for is a MAP — press it a moment early and the sheet carries a grey
|
|
// rectangle where the map should be.
|
|
const printing = ref(false)
|
|
const mapEl = ref(null)
|
|
|
|
async function printDashboard() {
|
|
if (printing.value) return // a second click during the wait must not start a second print
|
|
await printWhenReady({
|
|
// Ask the map itself. Absent (no map on this view, or it failed) the array is empty and we
|
|
// print straight away, which is correct: there is nothing to wait for.
|
|
waitFor: mapEl.value?.whenIdle ? [mapEl.value.whenIdle({ timeoutMs: 7000 })] : [],
|
|
onBusy: (v) => { printing.value = v },
|
|
// Under the map's own ceiling, so the map decides it first and this is only the backstop.
|
|
timeoutMs: 8000,
|
|
})
|
|
}
|
|
const loadFailed = ref(false)
|
|
// The current selection, as { regionSlug, countrySlug } — seeded from profile-config so a
|
|
// standalone embed can deep-link straight to one region's dashboard.
|
|
const sel = ref({
|
|
regionSlug: cfg.value.regionSlug || '',
|
|
countrySlug: cfg.value.countrySlug || '',
|
|
})
|
|
|
|
// ── identical field list + rollup to tab 1 ──────────────────────────────────
|
|
// A SUPERSET of tab 1's list: name + slug are added for the map popups and the country
|
|
// people-group list. Every ROLLUP field is unchanged, so the two tabs still roll up
|
|
// identically -- the extra fields are read by the map, never by addToStats().
|
|
// Their PEOPLE_GROUP_LIST_FIELDS plus the two this tab's map needs (name, slug) — derived
|
|
// from the shared list so the two can never drift apart.
|
|
// ★ PLUS THEIR FIVE POPUP FIELDS. CountryMap.vue:89 fetches
|
|
// name,slug,country_code,rop1,religion,population,location_description,image_url,has_photo,
|
|
// latitude,longitude,people_committed
|
|
// and their pin popup renders every one of them. Ours fetched neither rop1, religion,
|
|
// location_description nor the photo, so the popup COULD NOT have been a clone however it was
|
|
// written — the data was not on the page. Same one request, five more columns.
|
|
// ★ imb_people_description, NOT location_description. Their CountryMap's PIN_FIELDS names
|
|
// imb_people_description and the popup binds it (CountryMap.vue:89 and :114 on MASTER); the
|
|
// doxa-maps-build branch this repo's checkout is parked on still says location_description, and
|
|
// that is what we had cloned. The two are different prose: location_description says where a
|
|
// people live, imb_people_description describes the people. So our popup was showing the wrong
|
|
// paragraph — visible content, not a field-name detail.
|
|
const FIELDS = PEOPLE_GROUP_POINT_FIELDS + ',' + PEOPLE_GROUP_LIST_FIELDS
|
|
|
|
function formatNumber(n) {
|
|
// THEIRS, RegionDetail.vue:16: `n.toLocaleString(`${locale.value}-u-nu-latn`)` — locale
|
|
// grouping separators with Latin digits. This was hardcoded 'en-US', which is only
|
|
// correct for English: de/fr/es group with '.' and ar would otherwise get Arabic-Indic
|
|
// digits. The -u-nu-latn keeps Latin digits, which is the half theirs pins deliberately.
|
|
// A bad tag throws RangeError in toLocaleString, so fall back rather than blank the page.
|
|
const v = Number(n || 0)
|
|
try { return v.toLocaleString(`${locale.value || 'en'}-u-nu-latn`) } catch (_) { return v.toLocaleString('en-US') }
|
|
}
|
|
|
|
|
|
// ── the selected subject ────────────────────────────────────────────────────
|
|
const selectedRegion = computed(() =>
|
|
regions.value.find((r) => r.slug === sel.value.regionSlug) || null
|
|
)
|
|
const selectedCountry = computed(() => {
|
|
if (!sel.value.countrySlug) return null
|
|
const r = selectedRegion.value
|
|
const pool = r ? r.countries : regions.value.flatMap((x) => x.countries)
|
|
return pool.find((c) => c.slug === sel.value.countrySlug) || null
|
|
})
|
|
|
|
// The subject line — a country if one was clicked, else the region, else everything.
|
|
const subject = computed(() => {
|
|
if (selectedCountry.value) {
|
|
const c = selectedCountry.value
|
|
return { kind: 'country', title: c.name, context: c.region?.label || t('Other'), stats: c }
|
|
}
|
|
if (selectedRegion.value) {
|
|
const r = selectedRegion.value
|
|
return { kind: 'region', title: r.label || t('Other'), context: tf('Countries: %count%', { count: formatNumber(r.countryCount) }), stats: r }
|
|
}
|
|
const all = emptyStats()
|
|
for (const r of regions.value) {
|
|
all.count += r.count; all.population += r.population; all.adopted += r.adopted
|
|
all.engaged += r.engaged; all.withPrayer += r.withPrayer
|
|
all.withFullPrayer += r.withFullPrayer; all.peopleCommitted += r.peopleCommitted
|
|
}
|
|
// t('All Regions'), not the literal. Their own key, translated in all eleven locales
|
|
// ('جميع المناطق' at ar), and this file ALREADY calls t('All Regions') for the back-link —
|
|
// so the same string was translated in one place and hardcoded in another. MEASURED at ar
|
|
// and zh: this title rendered English inside an otherwise fully localised element.
|
|
return { kind: 'all', title: t('All Regions'), context: tf('Regions: %count%', { count: formatNumber(regions.value.length) }), stats: all }
|
|
})
|
|
|
|
// ★ NO BAR CHART. It was ours, not theirs, and the rule is that a SELECTED region or country
|
|
// must look IDENTICAL to its DX-life page — RegionDetail.vue has four stat cards, the map, three
|
|
// goal cards and the country table, and nothing else. The one deliberate difference in this
|
|
// element is the NO-SELECTION state below, which their site has no route for at all.
|
|
|
|
// ── DX-life's RegionDetail, field for field ──────────────────────────────────
|
|
// Ported from root-dx-life/doxa-life/app/components/public/RegionDetail.vue (read-only clone,
|
|
// standing law). Every figure below is one THEIR component also computes, from the rollup we
|
|
// already had -- nothing here is a new metric we invented to fill a card.
|
|
|
|
// Their prayer split, RegionDetail.vue:24-26: fully covered (100+ committed), partially covered
|
|
// (1+), and nobody yet. partial/none are DERIVED, never fetched, so the three always total count.
|
|
const partialPrayerCount = computed(() => Math.max(0, (subject.value.stats.withPrayer || 0) - (subject.value.stats.withFullPrayer || 0)))
|
|
const noPrayerCount = computed(() => Math.max(0, (subject.value.stats.count || 0) - (subject.value.stats.withPrayer || 0)))
|
|
|
|
// The three prayer-coverage bands as ONE list, feeding both the bar widths and the legend numbers.
|
|
// ProgressBar divides each `value` by its `max`, so a width and the figure printed beside it come
|
|
// from the same number and cannot drift apart -- before extraction they were computed separately.
|
|
// The colours are the very tokens the deleted .rd-seg--full/--partial/--none rules set, so every
|
|
// rendered colour is unchanged; only the mechanism moved from a class to a value.
|
|
const prayerSegments = computed(() => [
|
|
{ value: subject.value.stats.withFullPrayer || 0, color: 'var(--dx-accent)', label: t('100+ People Praying') },
|
|
{ value: partialPrayerCount.value, color: 'var(--dx-partial)', label: t('1+ People Praying') },
|
|
{ value: noPrayerCount.value, color: 'var(--dx-track)', label: t('No One Praying') },
|
|
])
|
|
|
|
// Their four headline stats, RegionDetail.vue:52-69: People Groups / Countries / Population /
|
|
// Unengaged. A COUNTRY subject has no countryCount, and their own country page does not show one
|
|
// either -- so that slot becomes Engaged, which the rollup measures for a country identically.
|
|
const stats = computed(() => {
|
|
const s = subject.value.stats || emptyStats()
|
|
const unengaged = Math.max(0, (s.count || 0) - (s.engaged || 0))
|
|
const four = [{ label: t('People Groups'), value: formatNumber(s.count) }]
|
|
if (subject.value.kind === 'country') four.push({ label: t('Engaged'), value: formatNumber(s.engaged) })
|
|
else four.push({ label: t('Countries'), value: formatNumber(subject.value.kind === 'all' ? totalCountryCount.value : (selectedRegion.value?.countryCount || 0)) })
|
|
four.push({ label: t('Population'), value: formatNumber(s.population) })
|
|
four.push({ label: t('Unengaged people groups'), value: formatNumber(unengaged) })
|
|
return four
|
|
})
|
|
|
|
const totalCountryCount = computed(() => regions.value.reduce((n, r) => n + (r.countryCount || 0), 0))
|
|
|
|
// Their three goal cards, RegionDetail.vue:88-141. The goal SENTENCES are theirs verbatim --
|
|
// they are the DOXA goals, not copy we get to reword.
|
|
|
|
// Their per-country table, RegionDetail.vue:144-171 -- five columns, same order.
|
|
// A country subject keeps its REGION's rows with itself highlighted, for the reason the chart
|
|
// already gives: one lone row is a number without a scale.
|
|
const tableRows = computed(() => {
|
|
if (subject.value.kind === 'all') {
|
|
return regions.value.map((r) => ({
|
|
key: r.slug, name: r.label || t('Other'), count: r.count, withPrayer: r.withPrayer,
|
|
adopted: r.adopted, engaged: r.engaged, isSelected: false, kind: 'region',
|
|
}))
|
|
}
|
|
const r = selectedRegion.value
|
|
const rows = r ? r.countries : []
|
|
return rows.map((c) => ({
|
|
key: c.slug, name: c.name, count: c.count, withPrayer: c.withPrayer,
|
|
adopted: c.adopted, engaged: c.engaged,
|
|
isSelected: !!selectedCountry.value && c.slug === selectedCountry.value.slug, kind: 'country',
|
|
}))
|
|
})
|
|
|
|
const tableTitle = computed(() => {
|
|
if (subject.value.kind === 'all') return t('Regions')
|
|
return t('Countries in this region')
|
|
})
|
|
|
|
// t('Region') has NO key in their catalogue — they never needed it, having no all-regions
|
|
// state — so it fell back to English. t('Regions') and t('Countries') both exist and are
|
|
// translated everywhere, and read correctly as column headings.
|
|
// The COUNTRY branch must be SINGULAR: their live /regions/africa table header reads
|
|
// "Country", measured, and t('Country') is in their catalogue and translated in every locale.
|
|
// I changed this to t('Countries') last tick for plural symmetry with the chooser dropdown and
|
|
// broke exact parity — I had checked that the key was translated but NOT that the word matched
|
|
// their page. The structural-text comparison added to _probe-region-vs-live.mjs caught it.
|
|
// The ALL branch keeps the plural: that column only exists in the chooser, which is ours, and
|
|
// t('Region') has no key in their catalogue because they have no all-regions state.
|
|
const tableFirstCol = computed(() => (subject.value.kind === 'all' ? t('Regions') : t('Country')))
|
|
|
|
// Their summary line, RegionDetail.vue:32-36 / the list's own. Kept as a %token% template for
|
|
// the same reason tab 1 keeps it -- it is how their i18n carries this string.
|
|
const summaryLine = computed(() => {
|
|
const s = subject.value.stats || emptyStats()
|
|
if (subject.value.kind === 'country') {
|
|
return formatNumber(s.count) + ' ' + t('People Groups') + ' \u00b7 ' + (selectedCountry.value?.region?.label || t('Other'))
|
|
}
|
|
const countries = subject.value.kind === 'all' ? totalCountryCount.value : (selectedRegion.value?.countryCount || 0)
|
|
return tf('%count% people groups in %countries% countries', {
|
|
count: formatNumber(s.count), countries: formatNumber(countries),
|
|
})
|
|
})
|
|
|
|
// ★ THE NO-SELECTION QUESTION, answered from what DX-life actually does.
|
|
// There is NO dashboard-without-a-subject route on their site: /regions is the LIST, and
|
|
// /regions/<slug> ALWAYS has a subject -- pages/regions/[slug].vue throws a 404 when the slug
|
|
// matches neither a region nor a country. So their answer is "that state does not exist; you are
|
|
// on the list instead". A 2-tab element cannot borrow that answer, because tab 2 is reachable
|
|
// before anything is clicked. Keeping their INVARIANT (the dashboard always has a subject) and
|
|
// making the widest honest subject the default gives: with nothing selected the subject is ALL
|
|
// REGIONS -- the same dashboard, every figure summed, and the table switched from countries to
|
|
// regions so the page still compares like with like. The prompt below then says how to narrow it.
|
|
const isAll = computed(() => subject.value.kind === 'all')
|
|
|
|
// ── THE CHOOSER'S MAP: colour mode + legend ─────────────────────────────────
|
|
// Only the no-selection state paints by region. A SELECTED page keeps DX-life's own modes
|
|
// exactly — prayer on a region, in-country on a country — because those pages are clones.
|
|
const mapColorMode = computed(() => {
|
|
if (subject.value.kind === 'country') return 'in-country'
|
|
if (subject.value.kind === 'region') return 'prayer'
|
|
return 'region'
|
|
})
|
|
|
|
// In REGION_ORDER, not by count: the palette's CVD separation was measured on ADJACENT pairs in
|
|
// that order, and sorting by size would also mean the legend reshuffles as data changes.
|
|
const regionLegend = computed(() => {
|
|
const byValue = new Map(regions.value.map((r) => [r.value, r]))
|
|
return REGION_ORDER
|
|
.map((value) => {
|
|
const r = byValue.get(value)
|
|
// PREFER THE API'S LOCALIZED LABEL. This read WAGF_REGIONS[value] || r.label, so the
|
|
// hardcoded ENGLISH display name in library/colors/wagf-region.js always WON over r.label
|
|
// — which is the localized name the API returns for ?lang=, and the very name the region
|
|
// CARDS render. MEASURED at locale=ar and zh: the cards read "آسيا" / "中东" while this
|
|
// legend, inside the same element, read "Asia" and "Middle East". My code was discarding
|
|
// their localized data. WAGF_REGIONS stays as the FALLBACK, for a region the API returns
|
|
// with no label at all.
|
|
return r ? { value, slug: r.slug, label: r.label || WAGF_REGIONS[value], count: r.count, color: REGION_PALETTE[value] } : null
|
|
})
|
|
.filter(Boolean)
|
|
})
|
|
|
|
const hoveredRegion = ref('')
|
|
|
|
// A pin click in the chooser picks THAT PIN'S COUNTRY — the finest subject the map can express.
|
|
function onPickFromMap(d) {
|
|
const code = d?.code
|
|
if (!code) return
|
|
for (const r of regions.value) {
|
|
const c = r.countries.find((x) => x.code === code)
|
|
if (c) { sel.value = { regionSlug: r.slug, countrySlug: c.slug }; return }
|
|
}
|
|
}
|
|
|
|
// ── THE ONE DELIBERATE DIFFERENCE: the no-selection chooser ─────────────────
|
|
// DX-life has no route for a dashboard without a subject (/regions is the list,
|
|
// /regions/<slug> always has one, [slug].vue 404s otherwise). Opening tab 2 first is a state
|
|
// only this element can reach, so it gets the one thing their site cannot give: a GENERIC map
|
|
// of everything, plus three independent ways to choose a subject — a region dropdown, a country
|
|
// dropdown, and the all-regions table, whose rows are already clickable. Once a subject is
|
|
// chosen the page becomes the faithful clone and this chooser disappears.
|
|
const regionOptions = computed(() =>
|
|
regions.value.map((r) => ({ value: r.slug, label: r.label || t('Other'), count: r.count }))
|
|
)
|
|
const countryOptions = computed(() =>
|
|
regions.value
|
|
.flatMap((r) => r.countries.map((c) => ({ value: c.slug, label: c.name, count: c.count })))
|
|
.sort((a, b) => a.label.localeCompare(b.label))
|
|
)
|
|
|
|
function onPickRegion(e) {
|
|
sel.value = { regionSlug: e?.value || '', countrySlug: '' }
|
|
}
|
|
function onPickCountry(e) {
|
|
const slug = e?.value || ''
|
|
const owner = regions.value.find((r) => r.countries.some((c) => c.slug === slug))
|
|
sel.value = { regionSlug: owner?.slug || '', countrySlug: slug }
|
|
}
|
|
|
|
// ── the map's subject: which countries to highlight, and how to frame them ──
|
|
// DX-life gets centre/zoom/bounds from config/countries-meta.ts, a build-time table we do not
|
|
// have. Deriving the frame from the FOCUS POINTS themselves is better, not a workaround: their
|
|
// own comment at CountryMap.vue:177-178 says bounds take precedence over centre/zoom because the
|
|
// frame should fit the real data rather than "the build-time approximation".
|
|
const focusCodes = computed(() => {
|
|
if (selectedCountry.value) return [selectedCountry.value.code]
|
|
if (selectedRegion.value) return selectedRegion.value.countries.map((c) => c.code)
|
|
return regions.value.flatMap((r) => r.countries.map((c) => c.code))
|
|
})
|
|
|
|
const mapBounds = computed(() => {
|
|
// ★ NEVER fitBounds THE WHOLE WORLD. In the chooser the focus is every country, so the bounds
|
|
// span ~180 degrees of longitude and Mapbox fits that by pulling the globe into a crescent:
|
|
// measured at 1440x1000 it rendered as a skewed arc of pins over mostly empty white, which for
|
|
// the one screen that has to invite a choice is the worst possible first impression. A plain
|
|
// world view is both correct and what a chooser wants. This stays a deliberate exception.
|
|
if (isAll.value) return null
|
|
// ★ THEIR FRAME, from countries-meta's viewFor()/lngExtent(), NOT a min/max box.
|
|
// A naive min/max has no antimeridian handling, and MEASURED on Oceania that was catastrophic:
|
|
// our box spanned 353.2 degrees — the long way round the globe — where theirs spans 44.0 along
|
|
// the short Pacific arc. The ported rollup already computes the right answer per country and
|
|
// per region; this component was still deriving its own and throwing that away.
|
|
const subj = selectedCountry.value || selectedRegion.value
|
|
return (subj && subj.bounds) || null
|
|
})
|
|
|
|
// Used when bounds cannot be formed (a single people group, or none plotted yet) — and now
|
|
// carrying THEIR centre rather than an arithmetic mean of the pins.
|
|
const mapCenter = computed(() => {
|
|
// The chooser gets a fixed world view rather than a centroid: the mean of every people group
|
|
// sits in south Asia (1309 of 2107 are in Asia), which framed the world off-centre and cut
|
|
// the Americas. 10E/20N with the zoom below puts every region on screen at once.
|
|
if (isAll.value) return [10, 28] // shifted north: the poles crop, and the data is northern
|
|
// Theirs is fitView's rounded midpoint of the SHORT-ARC box. The mean of the pins is a
|
|
// different point entirely: for Oceania it landed at [28.02, -16.06], in Africa, because the
|
|
// longitudes average across the antimeridian. Theirs is [165.9, -13.8], in the Pacific.
|
|
const subj = selectedCountry.value || selectedRegion.value
|
|
if (subj && Array.isArray(subj.center)) return subj.center
|
|
return [20, 10] // countries-meta's WORLD_VIEW centre, for a subject with no coordinates
|
|
})
|
|
const mapZoom = computed(() => {
|
|
// Mercator's world is square (512*2^z on both axes) and the frame is 16/9, so a world view
|
|
// cannot fill both. Fill the WIDTH (512*2^z ~ 1200 -> z ~ 1.23) and let the ice caps crop.
|
|
if (isAll.value) return 1.25
|
|
// Theirs, from fitView: the tighter of the lat/lng fit, minus NEIGHBOUR_PADDING 0.6 so
|
|
// surrounding countries stay visible, clamped to [1.6, 6]. This was a hardcoded 4-or-2, which
|
|
// is right for no region and no country in particular.
|
|
const subj = selectedCountry.value || selectedRegion.value
|
|
if (subj && typeof subj.zoom === 'number') return subj.zoom
|
|
return selectedCountry.value ? 4 : 2
|
|
})
|
|
|
|
// Their map notes, verbatim, with the same %token% substitution their i18n uses.
|
|
// RegionDetail.vue:38-42 and CountryDetail.vue:29-33.
|
|
const mapNote = computed(() => {
|
|
if (selectedCountry.value) {
|
|
return tf('There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.', {
|
|
count: formatNumber(selectedCountry.value.count), country: selectedCountry.value.name,
|
|
})
|
|
}
|
|
const label = subject.value.title
|
|
const countries = subject.value.kind === 'all' ? totalCountryCount.value : (selectedRegion.value?.countryCount || 0)
|
|
// THE CHOOSER PAINTS BY REGION, so their sentence — which promises "Each pin is coloured by how
|
|
// many people are praying for that people group" — is FALSE here. Found by LOOKING at the page:
|
|
// every assertion was green, including the two that measure the pins ARE region-painted and the
|
|
// prayer key is absent, because nothing compared their prose against the actual paint.
|
|
// Their sentence is ONE i18n key, translated in all 11 locales, so it cannot be split (a '. '
|
|
// split breaks on zh/ar punctuation) and a replacement clause would be copy I would have to
|
|
// invent in 11 languages. So the chooser simply drops the claim: the subtitle above already
|
|
// states both counts, and the region key below the map is what explains the colours.
|
|
// Their own pages keep the sentence verbatim — it is true there, and measured identical to live.
|
|
if (subject.value.kind === 'all') return ''
|
|
return tf('%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.', {
|
|
region: label, count: formatNumber(subject.value.stats.count), countries: formatNumber(countries),
|
|
})
|
|
})
|
|
|
|
// The country page's list is DX-life's own UupgsList, ported at src/UupgsList.vue — not a table
|
|
// 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: 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'
|
|
|
|
// ★ URL SYNC IS THE HOST'S TO REFUSE. The two capabilities above are ours when we ARE the page,
|
|
// and NOT ours when we are one tab inside someone else's. Nested in the Research Gallery this
|
|
// element rewrote the HOST page's #anchor through writeUrl() and retitled the host's tab through
|
|
// syncTitle() -- reported by rega's embed sweep, and carried as two routed entries in
|
|
// check:gallery-embed's NEIGHBOUR_KNOWN (regions-dashboard:url and :title), deleted in this same
|
|
// commit now that it is honoured.
|
|
// The gallery passes profile-config.urlSync:false for exactly this. It is a COMPUTED rather than a
|
|
// second const because the injected profile-config arrives after this module evaluates, so a
|
|
// snapshot taken here would read `undefined` and sync anyway -- the same late-injection trap that
|
|
// made every value read off the string prop fall back to its default.
|
|
// DELIBERATELY NOT GUARDED: slugFromHash(), the boot-time READ. The complaint is about WRITING to a
|
|
// host we do not own, and refusing to read would change which subject a standalone deep link opens.
|
|
// The residual is narrow and worth stating: on a host whose own #anchor happens to equal one of
|
|
// their slugs, a nested Regions would open that subject instead of the chooser.
|
|
const urlSyncEnabled = computed(() => URL_SYNC && cfg.value.urlSync !== false)
|
|
|
|
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 (!urlSyncEnabled.value) return
|
|
const slug = currentSlug()
|
|
const url = window.location.pathname + window.location.search + (slug ? '#' + encodeURIComponent(slug) : '')
|
|
// WRITE ONLY WHEN THE URL WOULD ACTUALLY CHANGE. This is what makes Back and Forward work, and
|
|
// it replaces a flag that could not do the job. MEASURED before the fix, drilling
|
|
// All Regions -> Africa -> Angola and then pressing Back three times and Forward twice:
|
|
// back 1 -> Africa (correct)
|
|
// back 2 -> Africa (WRONG, should be All Regions)
|
|
// back 3 -> Africa
|
|
// forward 1 -> Africa (WRONG, should be Angola)
|
|
// history.length stayed 4 throughout: every Back pushed a REPLACEMENT entry, which discards the
|
|
// forward stack and pins the position — the classic loop the note below warns about.
|
|
// The old guard set applyingHistory=true, assigned sel, then cleared it SYNCHRONOUSLY, while
|
|
// the watcher that calls this is { flush: 'post' } and therefore runs after the DOM update,
|
|
// by which time the flag was already false. A scheduling-dependent flag cannot guarantee this;
|
|
// comparing the URL can. After a popstate the address bar already holds the target, so there is
|
|
// nothing to write and no entry is created.
|
|
const current = window.location.pathname + window.location.search + window.location.hash
|
|
if (url === current) { syncTitle(); return }
|
|
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
|
|
// The host owns its tab title when we are nested; see urlSyncEnabled.
|
|
if (!urlSyncEnabled.value) return
|
|
const t = subject.value?.title
|
|
// COMPARE AGAINST THE TRANSLATED VALUE, not the English literal. The subject title is now
|
|
// t('All Regions'), so at ar it is 'جميع المناطق' and a comparison with the English string
|
|
// never matched — the no-selection state would have produced 'جميع المناطق — Regions'
|
|
// instead of the bare fallback. Both halves go through t(), whose keys are theirs and are
|
|
// translated in all eleven locales.
|
|
const allLabel = t2('All Regions')
|
|
const suffix = t2('Regions')
|
|
try { document.title = t && t !== allLabel ? t + ' — ' + suffix : suffix } catch (_) {}
|
|
}
|
|
|
|
// popstate WRITES the selection, and the watcher below then runs — but it can no longer create an
|
|
// entry for it, because writeUrl() refuses to write a URL the address bar already holds. The flag
|
|
// that used to guard this is GONE rather than patched: it was cleared synchronously while the
|
|
// watcher it guarded is { flush: 'post' }, so it was never in effect when it mattered.
|
|
function onPopState() {
|
|
// A host that refused URL sync also owns its own history entries.
|
|
if (!urlSyncEnabled.value) return
|
|
sel.value = selectionForSlug(slugFromHash())
|
|
syncTitle()
|
|
}
|
|
|
|
// 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.
|
|
// ★ A NEW SUBJECT OPENS AT ITS TOP. Picking a region from the all-regions table, or a country from a region,
|
|
// kept the old scroll height, so the reader landed at the bottom of the new page (reported on a phone). The
|
|
// scroller is this dashboard's own <main>, the one the Back-to-top button drives. Switching to the list and
|
|
// back selects nothing new, so the selected dashboard keeps its place there. A string key, because an array
|
|
// literal is a new object on every run and would fire on no change.
|
|
watch(() => `${sel.value.regionSlug}|${sel.value.countrySlug}`, () => {
|
|
const el = scrollEl.value
|
|
if (el && el.scrollTop > 0) el.scrollTo({ top: 0, behavior: 'auto' })
|
|
}, { flush: 'post' })
|
|
|
|
watch(() => [sel.value.regionSlug, sel.value.countrySlug], () => {
|
|
// No guard needed: writeUrl() is idempotent, so the call this watcher makes after a popstate
|
|
// writes nothing. (Deleting the declaration while leaving `if (applyingHistory) return` here
|
|
// threw a ReferenceError inside the watcher, which silently stopped the URL being written at
|
|
// all — the hash never changed and Back left the page. Caught by re-running the measurement
|
|
// rather than by trusting the fix.)
|
|
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
|
|
// their index page, and reachable without leaving the tab.
|
|
function clearSelection() {
|
|
sel.value = { regionSlug: '', countrySlug: '' }
|
|
}
|
|
|
|
// Their table's first column is a NuxtLink to /regions/<slug> (RegionDetail.vue:166). Here the
|
|
// same click re-points THIS dashboard: a region row on the all-regions table drills into that
|
|
// region, a country row into that country. Same destination, no navigation.
|
|
function selectFromTable(row) {
|
|
if (row.kind === 'region') sel.value = { regionSlug: row.key, countrySlug: '' }
|
|
else sel.value = { regionSlug: sel.value.regionSlug || selectedRegion.value?.slug || '', countrySlug: row.key }
|
|
}
|
|
|
|
// ── cross-pane seam: the composite forwards tab 1's selection here ──────────
|
|
const SELECT_EVENT = 'doxa-regions-dashboard:select'
|
|
|
|
function onSelect(e) {
|
|
const d = e?.detail || {}
|
|
// Scoped by instanceId exactly as upg-100-mfe's show-on-map seam is: an event carrying a
|
|
// DIFFERENT instance's id belongs to another embed on the same page and must be ignored.
|
|
if (d.instanceId && instanceId.value && d.instanceId !== instanceId.value) return
|
|
sel.value = { regionSlug: d.regionSlug || '', countrySlug: d.countrySlug || '' }
|
|
}
|
|
|
|
// ── data fetch ──────────────────────────────────────────────────────────────
|
|
async function loadRegions() {
|
|
const url = `${prayBaseUrl.value}/api/people-groups/list?fields=${FIELDS}&lang=${languageCode.value}`
|
|
loading.value = true
|
|
loadFailed.value = false
|
|
try {
|
|
const response = await fetch(url)
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
const data = await response.json()
|
|
// 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)
|
|
// The mapping moved to @map/utils/peopleGroupPoints.js when country-map became its second
|
|
// consumer — same fields, same has_photo gate, same drop of rows without finite coordinates.
|
|
points.value = peopleGroupPoints(posts, toNumber)
|
|
} catch (error) {
|
|
console.error('[region-dashboards] fetch error:', error)
|
|
loadFailed.value = true
|
|
regions.value = []
|
|
points.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// A locale change has to REFETCH, not just re-render: the labels come from the API's ?lang=,
|
|
// so without this the chrome switches language and the country names keep the language of
|
|
// first paint.
|
|
watch(languageCode, () => { loadRegions() })
|
|
|
|
onMounted(async () => {
|
|
window.addEventListener(SELECT_EVENT, onSelect)
|
|
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
|
|
// the two tabs does not change the surface under the reader.
|
|
// NO BACKTICKS in this string, including comments (house style: one terminates the literal).
|
|
useShadowStyles(
|
|
// Their _reset.scss:3-8 FIRST, so every rule below is authored against border-box exactly as
|
|
// theirs is. See src/shadowReset.js for why it lives there and for the measured blast radius.
|
|
SHADOW_BOX_SIZING_RESET +
|
|
DXLIFE_TOKENS +
|
|
// EVERY value below is DX-life's own, resolved against their 10px rem root
|
|
// (app/assets/styles/base/_globals.scss html{font-size:var(--font-size-base)}, and
|
|
// tokens/semantic/_typography.scss $font-size-base:10px). Sources, one per block:
|
|
// .container composition/_container.scss
|
|
// .card blocks/_cards.scss:9-37
|
|
// .switcher composition/_switcher.scss
|
|
// .progress-bar blocks/_progress-bar.scss
|
|
// .region-* RegionDetail.vue's own scoped block
|
|
// colours tokens/semantic/_colors.scss
|
|
// Spacing is the 1.33 modular scale: 2xs 5.65 / xs 7.52 / sm 13.3 / md 17.7 / lg 23.5 /
|
|
// xl 31.3 / 2xl 41.6 / 3xl 55.3. Type is the 1.25 scale off 10px: sm 15.6 / md 19.5 /
|
|
// lg 24.4 / 2xl 38.1 / 3xl 47.7 / 4xl 59.6 / 5xl 74.5.
|
|
// NO BACKTICKS in this string, including comments (house style: one terminates the literal).
|
|
':host{ display:block; position:relative; width:100%; height:100%; min-height:320px; }' +
|
|
'.rd-root{ position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;' +
|
|
// tokens/semantic/_colors.scss. --color-brand-lighter is declared TWICE there; the second
|
|
// wins, so it is green-300 #6d766e, not green-200.
|
|
' --dx-surface:#f3f3f1; --dx-elevated:#fff; --dx-text:#2a332a; --dx-header-bg:#3b463d;' +
|
|
' --dx-header-text:#fff; --dx-muted:#6d766e; --dx-line:#d1d4cd; --dx-accent:#92b195;' +
|
|
' --dx-dark:#2a332a; --dx-on-dark:#f3f3f1; --dx-track:#4e594f; --dx-partial:#f0c64a;' +
|
|
' --dx-radius:35px; --dx-radius-lg:25px;' +
|
|
// base/_globals.scss:99-105 + :205-207 style EVERY table on their site: --table-border-color
|
|
// is $color-gray-500 (#666) and --border-radius-lg is 2.5rem at their 10px root = 25px.
|
|
' --dx-table-border:#666;' +
|
|
// base/_globals.scss:132-141 styles EVERY anchor: --color-brand-light on hover to
|
|
// --color-brand-lighter. $color-green-400 / $color-green-300.
|
|
' --dx-link:#4e594f; --dx-link-hover:#6d766e;' +
|
|
' background:var(--dx-surface); color:var(--dx-text);' +
|
|
' font:var(--font-size-md)/1.5 Poppins,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }' +
|
|
// Same responsive context + same 1200px cap as tab 1 (regions-list.vue). DX-life applies its
|
|
// .container to the DETAIL routes too -- RegionDetail.vue:46 and CountryDetail.vue:35 are both
|
|
// class="container page-content ..." -- so the dashboard half is capped by the same rule.
|
|
// gap = their .stack--3xl (var(--spacing-3xl)), the gap between every block on that page.
|
|
// ★ 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.
|
|
// ★ 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.
|
|
// ★ THEIR MARGIN RESET, AS AN ELEMENT SELECTOR — and the specificity is the whole point.
|
|
// base/_reset.scss zeroes margins with `body,h1,h2,h3,h4,p,figure,blockquote,dl,dd { margin: 0 }`,
|
|
// a bare element selector at 0-0-1. Their `.stack > * + *` is 0-1-0, so the stack WINS and their
|
|
// heading gets its 10px. We had recreated that reset as CLASS rules (.rd-title{margin:0}), which
|
|
// is also 0-1-0 — `*` contributes nothing to specificity — so ours came down to source order and
|
|
// the later class rule beat the stack.
|
|
// MEASURED on live /regions/africa: their head is a(0px), h1(10px), p(10px). Ours was h1(0px),
|
|
// p(0px) — the title and strapline sat flush against the back-link, 10px tight, on every region
|
|
// and country page. Their .region-goal is 0px and ours matches, so that one was never a defect.
|
|
// Porting the reset properly and deleting the class-level margin:0 restores their cascade exactly.
|
|
'h1,h2,h3,h4,p,figure,blockquote,dl,dd{ margin:0; }' +
|
|
// ★ AND THEIR IMAGE RESET, the other half of base/_reset.scss that the shadow boundary blocked:
|
|
// img, picture { max-width: 100%; display: block }
|
|
// MEASURED on a COUNTRY page, where the uupg photos actually render: their img.uupg__image has
|
|
// max-width 100%, ours had max-width NONE, so an image wider than its grid cell overflows our
|
|
// card where theirs is constrained. display already matched, and height/border-radius/object-fit
|
|
// already match because our .uupg__image class sets them (100px, 7.5px, cover -- identical to
|
|
// theirs), so max-width was the whole gap.
|
|
// NOT PORTED, deliberately: _globals.scss:80's `img { border-radius: var(--border-radius-lg) }`.
|
|
// On their site that rounds every image because every image there is theirs. Applying it inside
|
|
// our root would also round the POSTER WALL previews, which are part of a sanctioned addition
|
|
// that does not exist on their page -- so porting it would change something they have no opinion
|
|
// about. The elements that ARE clones already carry the right radius from their own class.
|
|
'img,picture{ max-width:100%; display:block; }' +
|
|
'@media (prefers-reduced-motion: reduce){' +
|
|
' .rd-root, .rd-root *, .rd-root *::before, .rd-root *::after{' +
|
|
' animation-duration:0.01ms !important; animation-iteration-count:1 !important;' +
|
|
' transition-duration:0.01ms !important; scroll-behavior:auto !important; } }' +
|
|
'.rd-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; scrollbar-gutter:stable; -webkit-overflow-scrolling:touch;' +
|
|
' padding:var(--spacing-3xl) 0 var(--spacing-4xl); display:flex; flex-direction:column;' +
|
|
' container-type:inline-size; }' +
|
|
'.rd-scroll > * + *{ margin-top:var(--spacing-3xl); }' + // .stack .stack--3xl
|
|
'.rd-scroll > *{ width:calc(90% + 16px); max-width:1200px;' +
|
|
' margin-left:auto; margin-right:auto; }' +
|
|
'.rd-status{ padding:var(--spacing-xl); text-align:center; color:var(--dx-muted); font-size:var(--font-size-md); }' +
|
|
|
|
// ── ★ PRINT: LET THE DOCUMENT FLOW ONTO PAGES ───────────────────────────────────────────────
|
|
// "The header and footer don't show properly" has a structural cause, not a styling one. This
|
|
// page is an app shell: .rd-root is position:absolute inset:0 and .rd-scroll owns the scroll.
|
|
// On paper there is no viewport to be absolute INSIDE and no scrollbar to move, so the browser
|
|
// prints the one screenful that happens to be laid out and drops everything above and below it.
|
|
// Releasing both back into normal flow is what makes the whole dashboard printable at all.
|
|
'@media print{' +
|
|
' .rd-root{ position:static; inset:auto; height:auto; overflow:visible; }' +
|
|
' .rd-scroll{ overflow:visible; height:auto; max-height:none; padding:0; }' +
|
|
// Keep a heading with the block it introduces rather than orphaned at a page foot.
|
|
' .rd-head, .rd-stats, .rd-mapblock, .rd-section{ break-inside:avoid; }' +
|
|
' .rd-h2{ break-after:avoid; }' +
|
|
// Controls that only do something under a pointer. The back-to-top arrow is the clearest case:
|
|
// on paper it is a circle with an arrow in it and nothing to scroll.
|
|
' .rd-chooser, .btt, .rd-printrow{ display:none !important; }' +
|
|
'}' +
|
|
'.rd-status.rd-failed{ font-weight:600; }' +
|
|
|
|
// ── head: RegionDetail.vue:47-51, a .stack--xs (7.5px) block, centred ──
|
|
// ★ EVERY .stack HERE IS NOW THEIR MECHANISM, not `gap`. The goal card proved the two are not
|
|
// interchangeable (a child's margin-top:auto can override a margin and cannot override a gap),
|
|
// and the extended live diff then caught the residue: their .stack rowGap computes to `normal`
|
|
// because the spacing is margin, while ours read 17.689px. Same pixels, different code — and the
|
|
// requirement is that the CODE match, not just the screenshot. Converted, so the next
|
|
// margin-overriding child anywhere on this page behaves as it does on their page.
|
|
// .stack .stack--xs — --spacing-xs is 1rem at their 10px root.
|
|
'.rd-printrow{ margin:0; }' +
|
|
// Quiet by default — this is a secondary action under a title, not a call to action. The busy
|
|
// state changes the CURSOR and the opacity as well as the label, because on a fast connection
|
|
// the label may only be readable for a few hundred ms and the cursor is what a reader feels.
|
|
'.rd-print{ font:inherit; font-size:var(--font-size-sm); color:var(--dx-muted);' +
|
|
' background:transparent; border:1px solid var(--dx-border); border-radius:6px;' +
|
|
' padding:4px 12px; cursor:pointer; transition:color .2s ease, border-color .2s ease; }' +
|
|
'.rd-print:hover:not(:disabled){ color:var(--dx-text); border-color:var(--dx-accent); }' +
|
|
'.rd-print:focus-visible{ outline:2px solid var(--dx-accent); outline-offset:2px; }' +
|
|
'.rd-print[aria-busy="true"]{ cursor:progress; opacity:.7; }' +
|
|
'.rd-head{ display:flex; flex-direction:column; text-align:center; }' +
|
|
'.rd-head > * + *{ margin-top:10px; }' +
|
|
'.rd-backlink{ margin:0; }' +
|
|
// their .light-link on a dark-on-light page: brand-light, underlined on hover
|
|
// THEIR back-link is an <a class="light-link">, so it inherits the UA underline plus their
|
|
// own `a` rule (_globals.scss:132-138): font-weight medium, text-decoration-thickness 2px,
|
|
// text-underline-offset 4px — and .light-link recolours it to --color-brand-secondary =
|
|
// $color-beige-300 = #cbc5b9. MEASURED on their live /regions/cameroon: rgb(203, 197, 185)
|
|
// with text-decoration-line underline.
|
|
// Ours is a <button> because this element has no router, and a button inherits NEITHER the
|
|
// underline nor any link colour — so it was rendering #4e594f with text-decoration:none, on
|
|
// BOTH the region and country pages. Neither vs-live diff measured the back-link at all,
|
|
// which is why it survived; _probe-country-head-vs-live.mjs now covers it.
|
|
'.rd-light-link{ appearance:none; background:none; border:0; padding:0; margin:0; cursor:pointer;' +
|
|
' font:inherit; font-size:var(--font-size-md); color:#cbc5b9; font-weight:500;' +
|
|
' text-decoration:underline; text-decoration-thickness:2px; text-underline-offset:4px; }' +
|
|
'.rd-light-link:hover{ color:#d1d4cd; }' +
|
|
'.rd-light-link:focus-visible{ outline:2px solid var(--dx-accent); outline-offset:2px; border-radius:4px; }' +
|
|
// h1: _globals.scss clamp(--font-size-3xl, 7vw, --font-size-5xl) = clamp(var(--font-size-3xl),_,var(--font-size-5xl)),
|
|
// heading family, line-height 1. cqw not vw -- see tab 1 for why.
|
|
'.rd-title{ 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; }' +
|
|
// their .color-brand-lighter summary under the h1
|
|
// .color-primary on the highlighted word — --color-brand-primary, measured live as
|
|
// rgb(146,177,149). The h1 itself keeps --color-text-on-default.
|
|
'.rd-title__last{ color:var(--dx-accent); }' +
|
|
'.rd-summary{ color:var(--dx-muted); font-size:var(--font-size-md); }' +
|
|
'.rd-prompt{ margin:0; color:var(--dx-muted); font-size:var(--font-size-sm); }' +
|
|
|
|
// ── .card (blocks/_cards.scss:9-21) lives in library/composables/useCardSurface.js now: StatCard
|
|
// and GoalCard both need it, and both call useCardSurface(), which injects ONE copy per shadow
|
|
// root under a stable label. This element renders no .rd-card markup of its own any more, so it
|
|
// does not call it. If markup here ever needs the surface again, call useCardSurface() -- do not
|
|
// re-declare the rules, or there will be two copies to keep in step.
|
|
|
|
// ── .region-stats: RegionDetail.vue's scoped block. The GRID stays here and the CARD went to the
|
|
// library, deliberately: how many stats there are and how they wrap is this page's decision,
|
|
// while the look of one stat is the reusable part. See library/components/regions/StatCard.vue.
|
|
// ★ auto-FILL WITH A CAPPED TRACK MINIMUM. Reported: the Population card — the LAST of four —
|
|
// was centred in a box four times too wide on a tablet. Cause: auto-FIT collapses the
|
|
// empty tracks, so at 3 columns the lone card in row 2 stretched across the whole row. Desktop
|
|
// fits all four, which is why only desktop looked right.
|
|
// auto-fill alone fixes that and breaks desktop: at 1200px it makes SIX tracks for four cards,
|
|
// leaving two empty on the right and squeezing the cards to their 180px floor (dx-shepherd).
|
|
// So the track minimum is also capped at a quarter of the row: at most four tracks ever, and the
|
|
// orphan is never stretched. Measured at 1200/1000/900/800/768/700/600/500/390 — four columns
|
|
// down to 800, three to 600, two below, no empty tracks at any of them.
|
|
// ⚠ THIS NOW DIFFERS FROM dx-life, deliberately. Theirs is the same auto-fit rule
|
|
// (RegionDetail.vue:176) and stretches the same card, so the clone diverges here deliberately;
|
|
// the divergence is being carried back as a product question.
|
|
'.rd-stats{ display:grid;' +
|
|
' grid-template-columns:repeat(auto-fill, minmax(max(180px, calc((100% - 3 * var(--spacing-md)) / 4)), 1fr));' +
|
|
' gap:var(--spacing-md); }' +
|
|
'.rd-chart{ padding:var(--spacing-lg) clamp(var(--spacing-md), 4cqw, var(--spacing-xl)); }' +
|
|
// RegionDetail wraps the note and the map in a .stack--sm (13.3px). The note is their
|
|
// .text-center .color-primary .font-weight-bold -- --color-brand-primary at weight 700.
|
|
// The chooser's two controls, side by side and wrapping on a phone. Its own block so the
|
|
// clone below is untouched by it.
|
|
'.rd-chooser{ display:flex; flex-wrap:wrap; gap:13.3px; align-items:flex-end;' +
|
|
' justify-content:center; }' +
|
|
// .stack .stack--sm — --spacing-sm, 1.33rem.
|
|
// ── the map sat below the fold at 1440, and this is the part of that I can fix without
|
|
// ── touching the clone or the order ────────────────────────────────────────────────────────
|
|
// ARITHMETIC AT 1440, container 1200px, tokens resolved: padding-top 55.3 + head 147 + 3 gaps of
|
|
// 55.3 + chooser ~62 + stats ~110 + note 29 + 13.3 puts the map's TOP at ~583px, and the map is
|
|
// 780px tall because min-height:780px beats what 16/9 asks for at 1200 wide (675). Bottom ~1363.
|
|
// So a reader at 1440x900 sees roughly the top 190px of an 780px map: a sliver, which is what
|
|
// "below the fold" meant.
|
|
//
|
|
// WHAT THIS DOES AND DOES NOT DO. Tightening the three gaps above the map from --spacing-3xl to
|
|
// --spacing-xl and dropping the outsized title one step recovers ~99px, so ~286px of map shows
|
|
// instead of ~190 — about 50% more map on load. It does NOT get the whole map above the fold,
|
|
// and nothing that leaves the map at 780px can: you would need the map under ~320px tall. That
|
|
// fix exists (bound .rcm by viewport height) but it changes the map's rendered SIZE, which is a
|
|
// change to what the Campaign Server shows, so it is PROVISIONAL, pending a product decision.
|
|
//
|
|
// SCOPED TO THE ALL-REGIONS PAGE ONLY (.rd-root--all), and at >=1200px only. That page is ours;
|
|
// the region and country pages are the clone, and their vertical rhythm is measured against
|
|
// dx-life, so a global trim here would quietly un-clone three pages to improve one.
|
|
'@media (min-width:1200px){' +
|
|
' .rd-root--all .rd-scroll > .rd-chooser,' +
|
|
' .rd-root--all .rd-scroll > .rd-stats,' +
|
|
' .rd-root--all .rd-scroll > .rd-mapblock{ margin-top:var(--spacing-xl); }' +
|
|
' .rd-root--all .rd-title{ font-size:var(--font-size-3xl); }' +
|
|
'}' +
|
|
'.rd-mapblock{ display:flex; flex-direction:column; }' +
|
|
'.rd-mapblock > * + *{ margin-top:13.3px; }' +
|
|
'.rd-mapnote{ margin:0; text-align:center; color:#92b195; font-weight:700; font-size:var(--font-size-md); }' +
|
|
// .rounded-xlg on their <CountryMap>; the child takes border-radius:inherit from here.
|
|
'.rd-mapframe{ border-radius:var(--dx-radius); overflow:hidden; }' +
|
|
// ★ A SQUARE MAP ON PHONES, for a region and a country too (asked for on a phone: "like the all-regions
|
|
// map"). RegionCountryMap keeps DX Life's cloned 1/2 portrait under 768px for its other consumer (the
|
|
// country page's country-map), so the override lives HERE, scoped to this dashboard's frame: two classes
|
|
// outrank the component's one. The all-regions chooser was already square (.rcm--chooser).
|
|
'@media (max-width:768px){ .rd-mapframe .rcm{ aspect-ratio:1/1; } }' +
|
|
// The region key. Their token scale throughout so the one screen we add does not look
|
|
// foreign next to the clone: --spacing-xs/sm gaps, --font-size-sm, the 100px pill radius
|
|
// their chips use, and --color-brand-lighter for the count.
|
|
'.rd-regionkey{ list-style:none; margin:0; padding:0; display:flex; flex-wrap:wrap;' +
|
|
' gap:10px 13.3px; justify-content:center; }' +
|
|
'.rd-regionkey__item{ appearance:none; border:1px solid var(--dx-line); background:var(--dx-elevated);' +
|
|
' border-radius:100px; padding:var(--spacing-2xs) 13.3px; cursor:pointer; font:inherit;' +
|
|
' font-size:var(--font-size-sm); color:var(--dx-text); display:inline-flex; align-items:center;' +
|
|
' gap:var(--spacing-2xs); transition:border-color .15s ease, background-color .15s ease; }' +
|
|
'.rd-regionkey__item:hover{ border-color:var(--dx-header-bg); background:#fff; }' +
|
|
'.rd-regionkey__item:focus-visible{ outline:2px solid var(--dx-accent); outline-offset:2px; }' +
|
|
'.rd-regionkey__dot{ width:10px; height:10px; border-radius:100px; flex:0 0 auto; }' +
|
|
'.rd-regionkey__count{ color:var(--dx-muted); font-variant-numeric:tabular-nums; }' +
|
|
'.rd-regionkey__hint{ margin:0; text-align:center; color:var(--dx-muted); font-size:var(--font-size-sm); }' +
|
|
|
|
// ── .switcher[data-width="xl"]: composition/_switcher.scss. --switcher-width 70rem = 700px,
|
|
// gap --spacing-2xl (var(--spacing-2xl)). flex-basis calc((700px - 100%) * 999) is every-layout's
|
|
// switch: below 700px of container the basis goes hugely positive and each card takes a
|
|
// full row; above it, negative, so the three share the row. Ported as-is -- this IS their
|
|
// responsive rule for these cards, and it needs no media query.
|
|
'.rd-switcher{ display:flex; flex-wrap:wrap; gap:var(--spacing-2xl); }' +
|
|
// min-width:0 is REQUIRED for their basis to govern. A flex item's automatic minimum size is
|
|
// its MIN-CONTENT width, and MEASURED: our goal cards' min-content is ~490px (the h2's
|
|
// longest word plus 2x55.35px padding), so three of them could not share 1200px and the
|
|
// third wrapped to its own row -- 579/579/1200 instead of their 372/372/372. DX-life needs
|
|
// no such reset because Bebas Neue is far narrower than any fallback we can load.
|
|
'.rd-switcher > *{ flex-grow:1; flex-basis:calc((700px - 100%) * 999); min-width:0; }' +
|
|
// The last rule of their composition/_switcher.scss, ported for completeness rather than for
|
|
// effect: at six or more children every one takes a full row instead of sharing. MEASURED in the
|
|
// rendered page, not counted in the template: 3 direct children, widths 372/372/372 at 1440 --
|
|
// the same three goal cards their RegionDetail.vue lays out. (A static tag-count of this block
|
|
// said 4 and was wrong; the runtime count is the one that governs a :nth-last-child selector.)
|
|
// So the threshold of 6 is out of reach and the selector matches nothing today. It is carried
|
|
// because the brief is
|
|
// an exact clone and because a fifth or sixth goal card would otherwise silently diverge from
|
|
// their layout -- a one-line rule now, or a puzzle later about why six cards behave differently
|
|
// here than on doxa.life. The other two rules in that file are NOT ported and that is measured,
|
|
// not an oversight: .switcher-item{display:flex} never applies because their switcher's children
|
|
// are `.stack | card`, not `.switcher-item`, and .three-part-switcher is a different component
|
|
// that the regions page does not use.
|
|
'.rd-switcher > :nth-last-child(n + 6), .rd-switcher > :nth-last-child(n + 6) ~ *{ flex-basis:100%; }' +
|
|
// ★ A CONSEQUENCE OF THE FONT SUBSTITUTION, not a departure from their design.
|
|
// Their h2 is clamp(--font-size-2xl, 6vw, --font-size-4xl) and resolves to var(--font-size-4xl) at
|
|
// 1440 -- verified live. In Bebas that fits their 372.25px card (261.55px of inner width).
|
|
// MEASURED in our stand-in face: 'Engagement' is 380.1px at var(--font-size-4xl), 304.1px at var(--font-size-3xl),
|
|
// 265.3px at 41.6px -- all overflowing -- and 243.2px at var(--font-size-2xl), which fits. var(--font-size-2xl) is
|
|
// --font-size-2xl, the FLOOR OF THEIR OWN CLAMP: the largest step on their type scale our
|
|
// substituted face can render in their card. Pinning it there keeps THEIR LAYOUT (three
|
|
// cards across, heading on two lines) which is what the clone is actually for. The table
|
|
// heading keeps the full clamp -- it has 1200px and no such constraint.
|
|
// The three goal cards are library/components/regions/GoalCard.vue now, and the .stack--lg /
|
|
// margin-top:auto layout that aligns their goal lines went with them. The .rd-h2 rule below is
|
|
// still here because this element keeps ONE heading of its own -- the breakdown table's title.
|
|
// h2: _globals.scss clamp(--font-size-2xl, 6vw, --font-size-4xl) = clamp(var(--font-size-2xl),_,var(--font-size-4xl))
|
|
'.rd-h2{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl)); line-height:1;' +
|
|
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
|
' font-weight:400; }' +
|
|
'.rd-h2--start{ text-align:start; }' +
|
|
// ★ 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.
|
|
// The Cyrillic heading face now comes from the shared module rather than a third copy of the
|
|
// same rule — its output is byte-identical to what this file emitted before (verified), so this
|
|
// is a de-duplication and not a style change. See library/styles/portedGlobals.js for the
|
|
// measurement history and for the one global it DELIBERATELY does not port.
|
|
cyrillicHeading('.rd-title', '.rd-h2') +
|
|
// .rd-big / .rd-big-label / .rd-goal and the .stack--xs that separates a bar from its legend are
|
|
// in library/components/regions/GoalCard.vue and library/components/charts/ProgressBar.vue now,
|
|
// together with the measurement history that fixed each value. The `strong` rule below STAYS:
|
|
// UupgsList renders a <strong> in this same shadow root and depends on it.
|
|
// base/_globals.scss:200-202 — <strong> is --font-weight-medium (500) on their site, not the
|
|
// UA's bold. The 'Goal:' label in all three cards is a <strong>, so ours was rendering 700.
|
|
'.rd-root strong{ font-weight:500; }' +
|
|
|
|
// ── .progress-bar (blocks/_progress-bar.scss) + their .prayer-coverage-segments override and
|
|
// legend are library/components/charts/ProgressBar.vue now. The three --full/--partial/--none
|
|
// colour rules are GONE rather than left behind: a segment's colour is a prop there, passed as
|
|
// the same var(--dx-accent) / var(--dx-partial) / var(--dx-track) tokens these rules set, so a
|
|
// shared bar needs no vocabulary about prayer. A rule no selector can reach is not a fallback.
|
|
|
|
// ── the breakdown table: RegionDetail.vue's scoped block. min-width 560px inside an
|
|
// overflow-x:auto wrapper is how they keep five columns readable on a phone instead of
|
|
// crushing them -- the table scrolls sideways, the page does not.
|
|
// ★ base/_globals.scss:19-23 — their countries block is a <section>, and EVERY <section> on
|
|
// their site carries padding-top clamp(--spacing-2xl, 6vw, --spacing-4xl) and padding-bottom
|
|
// --spacing-4xl. At 1440 that is 73.614px above and below, ON TOP of the --spacing-3xl the
|
|
// page stack already puts between blocks: 128.96px of air above the heading, where ours had
|
|
// 55.35px and nothing below. Another global rule, another thing reading RegionDetail.vue
|
|
// alone could not tell me. Same clamp, in container units for the embedded case.
|
|
'.rd-section{ display:flex; flex-direction:column;' +
|
|
' padding-top:clamp(var(--spacing-2xl), 6cqw, var(--spacing-4xl));' +
|
|
' padding-bottom:var(--spacing-4xl); }' +
|
|
'.rd-section > * + *{ margin-top:var(--spacing-md); }' + // .stack .stack--md
|
|
|
|
'.rd-table-scroll{ overflow-x:auto; }' +
|
|
// ★ base/_globals.scss:99-105 — the part of their table that is NOT in RegionDetail's
|
|
// scoped block and was therefore missed: every <table> on their site is a rounded,
|
|
// ring-outlined WHITE card, and every <td> carries a 1px gridline (:205-207). Ours was a
|
|
// bare borderless table on the page background. So the table component was NOT the same, and the
|
|
// reason it was missed is that the rule lived in a global stylesheet, not next to
|
|
// the markup, so reading RegionDetail.vue alone could not show it.
|
|
// overflow:hidden is what makes border-radius clip the header's own background.
|
|
'.rd-table{ width:100%; min-width:560px; border-collapse:collapse; font-size:var(--font-size-md);' +
|
|
' border-radius:var(--dx-radius-lg); overflow:hidden; background:#fff;' +
|
|
' box-shadow:0 0 0 1px var(--dx-table-border); }' +
|
|
// td ONLY — their th has no border of its own, it has the header fill.
|
|
'.rd-table td{ border:1px solid var(--dx-table-border); }' +
|
|
'.rd-table th, .rd-table td{ padding:var(--spacing-2xs) 13.3px; text-align:start; }' +
|
|
'.rd-table th:not(:first-child), .rd-table td:not(:first-child){ text-align:end; }' +
|
|
'.rd-table th{ background:var(--dx-muted); color:var(--dx-on-dark); font-weight:500; }' +
|
|
'.rd-tr--selected{ background:#e8ece7; font-weight:600; }' +
|
|
// ★ Their country cell is a <NuxtLink>, i.e. an <a>, which picks up base/_globals.scss:132-141:
|
|
// --color-brand-light, --font-weight-medium, the UA underline at 2px thickness / 4px offset,
|
|
// hovering to --color-brand-lighter. Ours is a <button> (it selects a subject in place
|
|
// rather than navigating, which is the one thing this element does differently) and a
|
|
// button inherits NONE of that — it read as plain body text, unweighted and unlined.
|
|
// So the anchor rule is spelled out here: same element role, same appearance.
|
|
'.rd-row-link{ appearance:none; background:none; border:0; padding:0; margin:0; cursor:pointer;' +
|
|
' font:inherit; font-weight:500; color:var(--dx-link); text-align:start;' +
|
|
' text-decoration:underline; text-decoration-thickness:2px; text-underline-offset:4px;' +
|
|
' text-decoration-skip-ink:auto; transition:color .3s ease; }' +
|
|
'.rd-row-link:hover{ color:var(--dx-link-hover); }' +
|
|
'.rd-row-link:focus-visible{ outline:2px solid var(--dx-accent); outline-offset:2px; border-radius:4px; }',
|
|
'region-dashboards'
|
|
)
|
|
</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="rd-root" :class="{ 'rd-root--all': isAll }" :lang="locale" :dir="isRTL ? 'rtl' : 'ltr'">
|
|
<!-- 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 ref="scrollEl" class="rd-scroll" :aria-label="t('Region dashboard')">
|
|
<div v-if="loading" class="rd-status">{{ t('Loading region dashboards…') }}</div>
|
|
<div v-else-if="loadFailed" class="rd-status rd-failed" role="status">{{ t('Region data could not be loaded. This is a connection problem, not a filter.') }}</div>
|
|
|
|
<!-- ★ ONE TEMPLATE, ONE MAP INSTANCE — not two branches each owning a <RegionCountryMap>.
|
|
MEASURED: with a branch per subject kind, switching region -> country UNMOUNTED one map
|
|
and MOUNTED another, and the new one initialised into a container that had no size yet.
|
|
It reported a 1200x780 canvas and drew NOTHING: layers [], m.loaded() false, a blank
|
|
white card. Every assertion I had passed, because "a canvas exists at size" is not the
|
|
same property as "the map drew something".
|
|
So the map is mounted ONCE here and re-framed by prop (applySubject), and the blocks
|
|
that genuinely differ between their two pages are the ones that come and go. DOM order
|
|
is chosen so each page reads in ITS OWN sequence with no CSS reordering:
|
|
region/all head · stats · map · chart · goals · table (RegionDetail.vue)
|
|
country head · map · people-group list (CountryDetail.vue) -->
|
|
<template v-else>
|
|
<!-- RegionDetail.vue:47-51 / CountryDetail.vue:37-45 — back-link, title, summary -->
|
|
<div class="rd-head">
|
|
<p v-if="!isAll" class="rd-backlink">
|
|
<button type="button" class="rd-light-link" @click="clearSelection">← {{ t('All Regions') }}</button>
|
|
</p>
|
|
<h1 class="rd-title">{{ titleParts.head ? titleParts.head + ' ' : '' }}<span class="rd-title__last">{{ titleParts.last }}</span></h1>
|
|
<!-- aria-busy, not just a changed label: a screen reader is told the control is working,
|
|
and :disabled is what actually prevents the second print. -->
|
|
<p class="rd-printrow">
|
|
<button
|
|
type="button"
|
|
class="rd-print"
|
|
:disabled="printing"
|
|
:aria-busy="printing ? 'true' : 'false'"
|
|
@click="printDashboard"
|
|
>{{ printing ? t('Preparing the page…') : t('Print') }}</button>
|
|
</p>
|
|
<p v-if="subject.kind === 'country'" class="rd-summary">
|
|
<!-- RAW, not formatNumber: CountryDetail.vue:40 is `{{ country.count }}` and that
|
|
component never defines formatNumber (RegionDetail does). Their own asymmetry. -->
|
|
{{ subject.stats.count }} {{ t('People Groups') }} ·
|
|
<button
|
|
type="button"
|
|
class="rd-light-link"
|
|
@click="selectFromTable({ kind: 'region', key: selectedRegion?.slug })"
|
|
>{{ subject.context }}</button>
|
|
</p>
|
|
<p v-else class="rd-summary">{{ summaryLine }}</p>
|
|
<p v-if="isAll" class="rd-prompt">
|
|
{{ t('Pick a region or a country on the Regions List tab to narrow this dashboard.') }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- THE CHOOSER — the no-selection state, and the only thing here DX-life does not have -->
|
|
<div v-if="isAll" class="rd-chooser">
|
|
<!-- Labels and placeholders through t(), using THEIR existing keys rather than new
|
|
ones: 'Regions', 'Countries' and 'Type to search...' are all in their common.json
|
|
and translated in every locale we ship. These were static attributes, so at ar the
|
|
element's one KEYBOARD/SCREEN-READER path — which the brief calls load-bearing,
|
|
since pin clicking is mouse-only — was labelled in English inside an otherwise
|
|
localised page. The plural reads naturally on a selector and costs no new strings.
|
|
The placeholders were invisible to a text-node scan, which is its own lesson. -->
|
|
<FilterDropdown
|
|
:label="t('Regions')"
|
|
name="region"
|
|
:options="regionOptions"
|
|
value=""
|
|
:placeholder="t('Type to search...')"
|
|
@filter-change="onPickRegion"
|
|
@filter-clear="clearSelection"
|
|
/>
|
|
<FilterDropdown
|
|
:label="t('Countries')"
|
|
name="country"
|
|
:options="countryOptions"
|
|
value=""
|
|
:placeholder="t('Type to search...')"
|
|
@filter-change="onPickCountry"
|
|
@filter-clear="clearSelection"
|
|
/>
|
|
</div>
|
|
|
|
<!-- RegionDetail.vue:53-70 — four stat cards. CountryDetail has none. -->
|
|
<div v-if="subject.kind !== 'country'" class="rd-stats">
|
|
<StatCard v-for="st in stats" :key="st.label" :value="st.value" :label="st.label" />
|
|
</div>
|
|
|
|
<!-- RegionDetail.vue:72-86 and CountryDetail.vue:47-61 — a .stack--sm of the note and the
|
|
map. color-by-prayer is theirs on the REGION page only; the country page passes it
|
|
not at all, which is what makes its pins read in-country vs surrounding. -->
|
|
<div class="rd-mapblock">
|
|
<p v-if="mapNote" class="rd-mapnote">{{ mapNote }}</p>
|
|
<div class="rd-mapframe">
|
|
<RegionCountryMap
|
|
ref="mapEl"
|
|
:country-codes="focusCodes"
|
|
:points="points"
|
|
:bounds="mapBounds"
|
|
:center="mapCenter"
|
|
:zoom="mapZoom"
|
|
:color-mode="mapColorMode"
|
|
:highlight-region="hoveredRegion"
|
|
:select-on-pin-click="isAll"
|
|
:pray-base-url="prayBaseUrl"
|
|
:research-url="researchUrl"
|
|
:country-url="countryUrl"
|
|
@pick-country="onPickFromMap"
|
|
/>
|
|
</div>
|
|
|
|
<!-- THE REGION KEY — chooser only, and it is a CONTROL as well as a key: the requirement
|
|
is a map that shows the regions and countries AND lets a reader select one.
|
|
Rendered in REGION_ORDER because that is the order the palette's CVD separation was
|
|
validated in. Buttons, not swatches with click handlers, so the keyboard reaches
|
|
them and check:a11y-button-names has a name to find. -->
|
|
<ul v-if="isAll" class="rd-regionkey" :aria-label="t('Choose a region')">
|
|
<li v-for="r in regionLegend" :key="r.value">
|
|
<button
|
|
type="button"
|
|
class="rd-regionkey__item"
|
|
:aria-label="tf('See %region% dashboard', { region: r.label || t('Other') })"
|
|
@click="sel = { regionSlug: r.slug, countrySlug: '' }"
|
|
@mouseenter="hoveredRegion = r.value"
|
|
@mouseleave="hoveredRegion = ''"
|
|
@focus="hoveredRegion = r.value"
|
|
@blur="hoveredRegion = ''"
|
|
>
|
|
<i class="rd-regionkey__dot" :style="{ background: r.color }" aria-hidden="true" />
|
|
<span class="rd-regionkey__label">{{ r.label }}</span>
|
|
<span class="rd-regionkey__count">{{ formatNumber(r.count) }}</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
<p v-if="isAll" class="rd-regionkey__hint">
|
|
{{ t("Pick a region above, or click any dot to open that people group's country.") }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- RegionDetail.vue:88-141 — the three DOXA goal cards in their .switcher -->
|
|
<div v-if="subject.kind === 'region'" class="rd-switcher">
|
|
<GoalCard
|
|
variant="secondary"
|
|
:title="t('Prayer Coverage')"
|
|
:value="formatNumber(subject.stats.peopleCommitted)"
|
|
:value-label="t('People committed to praying')"
|
|
:goal-label="t('Goal')"
|
|
:goal="t('Mobilize 100+ people praying daily for each DOXA people group')"
|
|
>
|
|
<ProgressBar
|
|
:max="subject.stats.count"
|
|
:segments="prayerSegments"
|
|
legend
|
|
:format="formatNumber"
|
|
:label="t('Prayer coverage progress')"
|
|
/>
|
|
</GoalCard>
|
|
|
|
<GoalCard
|
|
:title="t('Adoption Progress')"
|
|
:value="formatNumber(subject.stats.adopted) + ' / ' + formatNumber(subject.stats.count)"
|
|
:value-label="t('people groups adopted')"
|
|
:goal-label="t('Goal')"
|
|
:goal="t('Every unengaged people group adopted by a church committed to prayer, giving, and sending')"
|
|
>
|
|
<ProgressBar :value="subject.stats.adopted" :max="subject.stats.count" />
|
|
</GoalCard>
|
|
|
|
<GoalCard
|
|
:title="t('Engagement Progress')"
|
|
:value="formatNumber(subject.stats.engaged) + ' / ' + formatNumber(subject.stats.count)"
|
|
:value-label="t('people groups engaged')"
|
|
:goal-label="t('Goal')"
|
|
:goal="t('No unengaged people groups by 2033')"
|
|
>
|
|
<ProgressBar :value="subject.stats.engaged" :max="subject.stats.count" />
|
|
</GoalCard>
|
|
</div>
|
|
|
|
<!-- RegionDetail.vue:144-171 — the five-column breakdown. CountryDetail has no table. -->
|
|
<div v-if="subject.kind !== 'country'" class="rd-section">
|
|
<h2 class="rd-h2 rd-h2--start">{{ tableTitle }}</h2>
|
|
<div class="rd-table-scroll">
|
|
<table class="rd-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ tableFirstCol }}</th>
|
|
<th>{{ t('People Groups') }}</th>
|
|
<th>{{ t('1+ People Praying') }}</th>
|
|
<th>{{ t('Adopted') }}</th>
|
|
<th>{{ t('Engaged') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="row in tableRows" :key="row.key" :class="{ 'rd-tr--selected': row.isSelected }">
|
|
<td><button type="button" class="rd-row-link" @click="selectFromTable(row)">{{ row.name }}</button></td>
|
|
<td>{{ formatNumber(row.count) }}</td>
|
|
<td>{{ formatNumber(row.withPrayer) }}</td>
|
|
<td>{{ formatNumber(row.adopted) }}</td>
|
|
<td>{{ formatNumber(row.engaged) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CountryDetail.vue:63-69 — <UupgsList :lock-country-code hide-see-all-link />, and
|
|
nothing around it: their page puts no heading above this block. -->
|
|
<!-- C11, found by the bug-class sweep: RegionCountryMap already received :research-url and
|
|
this list did not, so a host configuring researchUrl got it honoured on the map popup's
|
|
Full Profile link and silently ignored by every profile link here. One config, two
|
|
surfaces, two answers — and the wrong one fell back to a hardcoded doxa.life default,
|
|
so it looked like a working link rather than a missing binding. -->
|
|
<UupgsList
|
|
v-if="subject.kind === 'country' && selectedCountry"
|
|
:lock-country-code="selectedCountry.code"
|
|
:pray-base-url="prayBaseUrl"
|
|
:language-code="languageCode"
|
|
:images-base="assetsBase + '/images'"
|
|
:research-url="researchUrl"
|
|
/>
|
|
</template>
|
|
</main>
|
|
<!-- Inside .rd-root, which is position:absolute inset:0, so the button's own absolute
|
|
positioning resolves against the plugin rather than the host page. -->
|
|
<BackToTopButton :target="scrollEl" :labels="{ backToTop: t('Back to top') }" />
|
|
</div>
|
|
</template>
|