Their heading highlight, and the page head tab 1 never had

A 68-of-68 computed-style diff was reporting the region page identical while two
visible things were wrong.

1. THEIR TITLES HIGHLIGHT THEIR LAST WORD. Every heading on their regions
surface is <h1 class="highlight" data-highlight-last>, and their
useTextHighlight wraps the final word in .color-primary. Measured live:
/regions/africa is ENTIRELY rgb(146,177,149) because "Africa" is the last word,
and /regions/middle-east renders 'Middle <span class="color-primary">East</span>'.
Ours was entirely dark.

★ HOW A 68/68 DIFF MISSED AN ENTIRELY GREEN HEADING: the h1 spec listed
fontSize, lineHeight, fontFamily, textAlign and textTransform — and not COLOUR.
The lesson is not "add colour to the list". It is that a property absent from
the spec is NOT MEASURED, and unmeasured is not identical. Same shape as the
font miss, where both sides named Poppins and only a glyph-width oracle showed
we were not rendering it. Colour now covers the h1, its highlight span, the stat
values, the headline figures and the goal lines: 68 -> 73 properties, still 0
differ. I found this reading their source for a different card, not from a check.

Ported declaratively rather than as their DOM walk. Theirs mutates innerHTML
once onMounted and stamps a processed flag — right for a Nuxt page whose title
never changes. Ours changes on every selection, so an imperative one-shot would
either fight Vue's re-render or go stale after the first subject. Same logic,
expressed where Vue can keep it true.

2. TAB 1 HAD NO PAGE HEAD AT ALL. pages/regions/index.vue:47-58 opens with that
h1 plus a .color-brand-lighter strapline; we rendered only the region cards.
Invisible inside the composite, where the tab strip names the view, and plainly
missing on the regions-list leaf, which is a page in its own right.

★ AND THE IMAGE CAUGHT MY OWN DEFECT. A whitespace-only <span> </span> between
two inline elements COLLAPSES: "Middle East" rendered "MIDDLEEAST". Every
assertion was green — the colour was right, the span was there, the text
content was right once whitespace-normalised. Only the screenshot showed it.
The space now lives inside the text interpolation, and an innerHTML assertion
guards it that was first shown to FAIL on the collapsed form.

Verified: region 73/73 vs live, country 28/28, mobile 44/0 and 26/0 at 390+768,
i18n, chooser and dashboard suites green, no gate newly red.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 03:22:15 +00:00
parent bc06a03ec6
commit efe2e506a8
8 changed files with 102 additions and 32 deletions

View file

@ -35,6 +35,7 @@ import UupgsList from './src/UupgsList.vue'
// 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 './src/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'
@ -95,6 +96,8 @@ useDocumentFont('Poppins', POPPINS_FACES, assetsBase.value)
// ── state ───────────────────────────────────────────────────────────────────
const { t, tf , locale } = useRegionsI18n()
// 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([])
@ -685,6 +688,9 @@ useShadowStyles(
' 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{ margin:0; color:var(--dx-muted); font-size:19.5312px; }' +
'.rd-prompt{ margin:0; color:var(--dx-muted); font-size:15.625px; }' +
@ -841,7 +847,7 @@ useShadowStyles(
<p v-if="!isAll" class="rd-backlink">
<button type="button" class="rd-light-link" @click="clearSelection">&larr; {{ t('All Regions') }}</button>
</p>
<h1 class="rd-title">{{ subject.title }}</h1>
<h1 class="rd-title">{{ titleParts.head ? titleParts.head + ' ' : '' }}<span class="rd-title__last">{{ titleParts.last }}</span></h1>
<p v-if="subject.kind === 'country'" class="rd-summary">
{{ formatNumber(subject.stats.count) }} {{ t('People Groups') }} &middot;
<button

View file

@ -31,6 +31,8 @@
*/
import { inject, ref, computed, onMounted, getCurrentInstance } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.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.
@ -79,6 +81,15 @@ 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 } = 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
@ -300,6 +311,15 @@ useShadowStyles(
// box-sizing:border-box so .rl-status's own 28px padding stays inside the cap.
'.rl-scroll > *{ box-sizing:border-box; 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.
'.rl-head{ display:flex; flex-direction:column; gap:10px; text-align:center; }' +
'.rl-title{ margin:0; font-size:clamp(47.6837px, 7cqw, 74.5058px); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; text-transform:uppercase; }' +
'.rl-title__last{ color:#92b195; }' +
'.rl-summary{ margin:0; color:var(--dx-muted); font-size:19.5312px; }' +
'.rl-status{ padding:28px; text-align:center; color:var(--dx-muted); font-size:19.5312px; }' +
'.rl-status.rl-failed{ font-weight:600; }' +
// .region-card: DX-life's --border-radius-xlg (35px), overflow hidden, white body, no border --
@ -381,6 +401,12 @@ useShadowStyles(
<template>
<div class="rl-root" :dir="isRTL ? 'rtl' : 'ltr'">
<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>
<div v-if="loading" class="rl-status">Loading regions…</div>
<div v-else-if="loadFailed" class="rl-status rl-failed" role="status">
Region data could not be loaded. This is a connection problem, not a filter.

View file

@ -0,0 +1,38 @@
/**
* useLastWordHighlight.js — their `.highlight` + `data-highlight-last` heading treatment.
*
* WHAT IT IS. Every page heading on DX-life's regions surface is
* `<h1 class="highlight" data-highlight-last>`, and app/composables/useTextHighlight.ts wraps the
* LAST WORD of that heading in `<span class="color-primary">`. So their titles read with the final
* word in brand primary:
* /regions/africa -> the whole title green, because "Africa" IS the last word
* /regions/middle-east -> "Middle" in ink, "East" in green
* MEASURED on the live pages: h1 colour rgb(42,51,42) with an inner span at rgb(146,177,149).
*
* ★ HOW IT WAS MISSED. A 68-of-68 computed-style diff of the region page reported no differences
* while our titles were entirely dark, because the diff compared the h1's fontSize, lineHeight,
* fontFamily and textAlign — and never its COLOUR, nor the existence of an inner span. The lesson
* is not "add colour to the list"; it is that a property absent from the spec list is not
* measured, and unmeasured is not identical.
*
* WHY DECLARATIVE AND NOT THEIR DOM WALK. Theirs mutates innerHTML once onMounted and stamps
* data-highlightProcessed so re-runs are cheap — correct for a Nuxt page whose title never
* changes. Our heading changes on every selection (chooser -> region -> country), so an
* imperative one-shot would either fight Vue's re-render or go stale after the first subject.
* Same logic, expressed where Vue can keep it true: split the words, wrap the last.
*/
/**
* Split a heading for last-word highlighting.
* @param {string} text
* @returns {{ head: string, last: string }} head = everything before the final word (may be ''),
* last = the final word. Their split is on any whitespace run, after trimming, so word
* boundaries are right regardless of how the template was formatted.
*/
export function splitLastWord(text) {
const words = String(text ?? '').trim().split(/\s+/).filter(Boolean)
if (!words.length) return { head: '', last: '' }
const last = words[words.length - 1]
const head = words.slice(0, -1).join(' ')
return { head, last }
}

View file

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

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-PkjCojT7LBeF8fuNq3Zm+b+UZZM3lqeypf6/5pVKRQLcwoaBge32MRu2pShn6DSz" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-W0oDAJmpV9sW5+jcEQSY6rPPcbw3+bjGjMNqDlBo8Py5eYgZ2bSKaijh88eA15a+" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-PkjCojT7LBeF8fuNq3Zm+b+UZZM3lqeypf6/5pVKRQLcwoaBge32MRu2pShn6DSz" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-W0oDAJmpV9sW5+jcEQSY6rPPcbw3+bjGjMNqDlBo8Py5eYgZ2bSKaijh88eA15a+" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-PkjCojT7LBeF8fuNq3Zm+b+UZZM3lqeypf6/5pVKRQLcwoaBge32MRu2pShn6DSz" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-W0oDAJmpV9sW5+jcEQSY6rPPcbw3+bjGjMNqDlBo8Py5eYgZ2bSKaijh88eA15a+" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: