doxa-maps/plugin-designs/my-upg-100-list/poster-wall.vue
Doxa Maps 03839179b7 Speak the reader's language on the UPG-100 map, its wall and the print HUD
The legend's region sections, its Deaf row and its one-country zones, the
wall's card names, the print progress and error messages, the map's status
badge and the Population column all rendered English in every locale. None
of them was visible to check:hardcoded-english: they were single words, .js
modules, Error() arguments, library components or number formatting.

- Legend: region rows through one REGION_KEY table shared with the wall
  (poster.region_*, new region_deaf); zones through zoneTitle (tier A by
  country, the Deaf row by its bloc name); node ids stay English-derived.
- Wall: card labels through zoneTitle, so the Show-on-map name, alt text,
  tooltip, HUD title and printed title agree with the legend and packet.
- Errors a reader sees are keyedError(key, English, params);
  errorText(e, t) shows them translated, and an unkeyed error still shows
  its own message. Developer detail for a failed data load goes to the
  console; the badge says upgMap.mapDataFailed.
- Population: Intl compact notation in the app's locale, same precision.
- Country Posters: the unsupported card's reason is
  messages.countryPosterTooMany, not the data file's English note.
- Strings only the UPG-100 map shows move to a new upgMap area, so other
  bundles do not carry them (country-posters 1,490,907 bytes).
- check:hardcoded-english judges keyedError's English as a fallback
  (quiet when the key resolves everywhere, a finding when it does not);
  check:label-key-coverage follows REGION_KEY to legend-tree.js and covers
  the legend's table and its regionFor producer. Both self-tests two-sided.
2026-09-29 08:33:43 +00:00

286 lines
18 KiB
Vue

<script setup>
/**
* poster-wall.vue — the UPG-100 POSTER WALL (pane 2 of the my-upg-100-list family).
*
* @description A wall of printable poster cards, one per 100-list entry, each with a "Show on map"
* action that hands off to the map profile in the same bundle.
*
* ★ MIGRATED ONTO THE SHARED WALL 2026-09-24. This file was 890 lines carrying its own copy of
* markup, CSS, the export modal and three print paths; the rendering now comes from
* @map/components/poster/PosterWall.vue, which country-posters (94 lines) and key-cities (75)
* already consume. This profile supplies ONLY what cannot be shared: its data, its taxonomy and
* its records provider.
*
* WHAT COULD NOT BE SHARED, and why each is here rather than in the component:
* REGION_OF / regionFor — the 100-list's own continental taxonomy, keyed by list id because a
* selector can be a wagf_block, a country_codes array or an explicit
* pg_slugs list and none of those implies a continent.
* fetchListRecords — knows the people-groups API, the selector evaluator AND the pg_slugs
* special case. Key Cities and Country Posters have entirely different
* data shapes, which is exactly why the shared wall takes a PROVIDER.
*
* THREE CAPABILITIES HAD TO BE ADDED TO THE SHARED WALL FIRST, over two ticks, because migrating
* without them would have REGRESSED this wall rather than consolidated it:
* cardStocks — the reader picks the prayer-card size at print time.
* posterAction="print" — open the print dialog instead of saving a file.
* hidePrayerListWhenSoon — no "coming soon" placeholder on a wall whose list DOES print.
* The last two are a Driver ruling, not a preference (2026-09-23): "three print buttons, each
* opening an in-browser print dialog, no silent file download as the default action. A raw
* 'Download' button and a 'Prayer List: coming soon' placeholder were exactly the two buttons he
* objected to."
*
* CROSS-PANE COMMS — unchanged and still the decoupling that matters: this wall NEVER imports the
* map. The shared component dispatches the window CustomEvent named below, scoped by instanceId,
* so several embeds on one page never cross-talk. The shell listens and drives the map pane.
*
* ASSETS. previews/<id>.webp (~65KB, 378 generated) SHIP and are what the wall paints. Full
* posters/<id>.webp do NOT ship by default (~42MB); export and print fall back to fetching
* posters/<id>.html and rasterising it in the browser. See
* IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF.
*/
import { computed, inject, ref } from 'vue'
import { useOptionalT } from '@map/composables/useOptionalT.js'
// The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups.
import { resolvePeopleGroupList } from '@map/lists/peopleGroupList.js'
import PosterWall from '@map/components/poster/PosterWall.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
// The reader's language, for the DATA fetch below. Not for interface text — that goes through t().
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
import { zonesForList } from './data/zones-for-list.js'
// The SAME normalize+evaluate pipeline the sibling map profile uses to turn the live
// /people-groups/list payload + a list's `selector` into the exact rows that belong to it.
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
import { makeZoneSheet } from './data/zone-sheet-print.js'
import { REGION_KEY } from './data/legend-tree.js'
import { keyedError } from '@map/i18n/keyedError.js'
// A zone's title in the reader's language (tier A: its country; others keep their label) — the same
// generator the map's legend and the packet use, so a card, its alt text and its print say one name.
import { zoneTitle } from './data/zone-title.js'
import { countryName } from '@map/utils/countryName.js'
import { evaluate as evaluateSelector } from './data/selector-evaluator.js'
// The table the zone map sheets were generated from (generate.mjs labels each country with it), so a
// packet printed from this wall names page 1's countries in its own language, as the list's print does.
// Lazy: only a packet print needs it.
const iso3Names = () => import('@map/data/iso3-name.js').then((m) => m.ISO3_NAME)
const profileConfig = inject('profileConfig', ref(null))
const instanceId = computed(() => profileConfig.value?.instanceId || 'upg-100-wall')
const isDark = computed(() => profileConfig.value?.isDark === true)
// Assets resolve RELATIVE to the host page so the tree stays drop-portable —
// file://, a CDN subpath, Railway and Capacitor all behave identically.
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
// Config-only, fail-closed API base — SAME resolution as the sibling map profile. No default
// host: a dev harness that never sets window.MAP_APP_API_URL simply gets no PG-level data, and
// the shared wall hides the row-dependent buttons rather than failing at click time.
const API_BASE = (typeof window !== 'undefined' && window.MAP_APP_API_URL)
|| import.meta.env.VITE_API_BASE_URL
|| ''
// ─── REGIONAL SECTIONS ───────────────────────────────────────────────────────
// clusters.json carries no region field, so the mapping lives here. It is keyed by
// list id rather than derived from the selector: a selector can be a wagf_block, a
// country_codes array or an explicit pg_slugs list, and none of those reliably
// implies the continent a reader expects to browse by.
//
// SE Asia + Oceania are ONE section deliberately (the island chains read as a
// single sweep), and the Americas gathers Brazil, the Andean/Caribbean list and the
// US-bearing scatter list.
const REGION_OF = {
// Africa
'west-africa': 'Africa', 'sudan': 'Africa', 'east-africa': 'Africa',
'central-africa': 'Africa', 'southern-africa-islands': 'Africa',
// Middle East. Maghreb+Egypt is geographically North AFRICA but belongs here:
// the region is read as a cultural/linguistic sweep.
'maghreb-egypt': 'Middle East',
'middle-east-core': 'Middle East',
// CENTRAL ASIA is its own section. Brief: "the 55 UPG Pakistan map is wrongly
// added to Middle East, it is Central Asia." It had been folded into the Middle
// East as "the same sweep continuing east", but Pakistan, Afghanistan,
// Uzbekistan, Tajikistan, Kyrgyzstan and Kazakhstan are Central/South Asia —
// filing them under the Middle East misnames six countries to save a heading.
'uncovered-nas-a': 'Central Asia',
// Southeast Asia + Oceania
'laos': 'Southeast Asia + Oceania', 'oceania-indonesia': 'Southeast Asia + Oceania',
'mainland-se-asia-philippines': 'Southeast Asia + Oceania',
// Americas
'brazil': 'Americas', 'andean-caribbean': 'Americas', 'uncovered-scatter': 'Americas',
// Europe
'europe': 'Europe',
// India and China are their OWN sections, not one lump called "Asia". Between
// them they are 1,122 people groups across 12 lists — more than every other
// section combined — so collapsing them hides the two largest prayer fields in
// the dataset behind a single continental label. Matched by id prefix below.
}
// ⚠ TAKES AN ID, while the shared wall's groupBy takes the whole ITEM. The template
// adapts it — `:group-by="(item) => regionFor(item.id)"` — rather than changing this
// signature, because the id is what the taxonomy is keyed by and every prefix rule
// below reads it directly. Passing regionFor straight in built and loaded cleanly and
// then threw `c.startsWith is not a function` at first render: the whole wall was blank,
// 0 cards, with nothing wrong at the source layer.
// ★ THE ENGLISH VALUES ABOVE ARE GROUPING KEYS; THIS TRANSLATES AT RENDER (studio's split).
// REGION_OF keeps its English values as stable identity — changing them would re-key the
// grouping — and the heading a reader sees is resolved here, per locale, through REGION_KEY
// (data/legend-tree.js), the one table the legend's region rows translate through as well. Every
// heading has a poster.region_* key in all 11 locales, India and Other included.
const regionLabel = (english) => {
const key = REGION_KEY[english]
return key ? t(`poster.${key}`) : english // no key yet -> the English heading, not a raw key
}
const regionFor = (id) => {
if (REGION_OF[id]) return regionLabel(REGION_OF[id])
if (id.startsWith('asia-india-')) return regionLabel('India')
// "China + Mongolia": the northern zone declares Mongolia as a member country
// (its land is drawn and highlighted), so the section heading names both rather
// than folding a sovereign country under a China label.
if (id.startsWith('asia-china-')) return regionLabel('China + Mongolia')
return regionLabel('Other')
}
// One flat list of the zones the map paints — for WHICHEVER list is showing.
//
// ★ THROUGH zonesForList, not a static clusters.json import (pattern work, 2026-09-27). This wall already
// filtered its RECORDS by list (applyPeopleGroupList, below), so before this it showed another list's
// people groups arranged under UPG-100's zone names — half list-aware, which is the worst of the two
// states because it looks right. Now the zones follow the list too.
// Safe inside a computed: profileConfig is injected at :54 and this evaluates lazily, after setup —
// a module-level call would hit the temporal dead zone (it does in upg-100-list.vue, which is why that
// file resolves per call as well).
// ⚠ AND THE OLD COMMENT SAID "the same 27", which was already stale: the legend has 28 leaves, because
// buildLegendNodes collapses the two deaf_lists into one unified row. This spread still yields 27 because
// it reads the three geographic buckets and the deaf row is synthesised — so the number is right for what
// this list IS, and the old comment was describing the wrong thing.
const items = computed(() => {
const z = zonesForList(resolvePeopleGroupList(profileConfig))
const locale = getRuntimeLocale() || detectLocale()
return [
...(z.lists || []),
...(z.asia_lists || []),
...(z.uncovered_lists || []),
].filter(l => l && l.id)
// ★ THE CARD'S NAME IN THE READER'S LANGUAGE. item.label is the Show-on-map button's accessible
// name, every poster's alt text and tooltip, the print HUD's title and the printed documents' title
// — all five were English in every locale. zoneTitle declines (null) for a zone it cannot name, and
// that zone keeps its hand-written label, exactly as on the legend and the packet.
.map(l => { const title = zoneTitle(l, null, { countryName, locale }); return title ? { ...l, label: title } : l })
})
// ─── THE RECORDS PROVIDER — now the library's (lists/peopleGroupRecords.js) ─────────
// This wall kept its own fetch (the field list, the reader's language, list membership, normalisation
// and the pg_slugs special case). All of it is the print standard's records provider now, which every
// map shares, so a fix to any of it reaches every wall. What is THIS profile's is only the selector
// language: pg_slugs is understood by the provider; every other UPG-100 selector goes through the same
// evaluator the map uses, so the map, the list print and the card deck cannot disagree about membership.
const recordsFor = createPeopleGroupRecords({
apiBase: () => API_BASE,
lang: () => getRuntimeLocale() || detectLocale(),
profileConfig,
evaluate: (selector, records) => evaluateSelector(selector, records),
})
async function fetchListRecords(list) {
if (!API_BASE) throw keyedError('wall.hud.noApi', 'no people-groups service is configured')
return recordsFor(list.selector)
}
// ★ THE SHEET, GENERATED AT THE CLICK (the print standard: generate, store only previews): the zone's map
// drawn in the browser for the poster print and the packet's page 1 — data/zone-sheet-print.js, the one
// helper every UPG-100 surface prints a zone through.
const zoneSheet = makeZoneSheet({ allRecords: () => recordsFor.all(), assetsBase: () => assetsBase.value, profileConfig })
// The wall's own copy. v-html in the shared component, so the <strong> lands as markup —
// the emphasis is the point: praying for them BY NAME is the whole brief.
//
// ★ THROUGH t(), NOT A LITERAL. rega measured ?lang=ar and found this intro, the heading and
// the tag rendering in English while the wall's buttons beside them were translated — the
// copy a reader reads FIRST was the untranslated part. The <strong> travels inside the
// translated string so each language can emphasise its own words, which is not something a
// wrapper around an English sentence could do.
// Guarded like every other useI18n() here: it throws on a host without vue-i18n. useOptionalT stores
// that guard once (host i18n -> catalogue -> the key) instead of a hand-rolled copy.
// No namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
//
// ★ ONE BEHAVIOUR GAIN FROM WAVE 3c (regions-plugin, 4c05e589): the old path was the bare
// useI18n().t, so on a host WITH i18n a missing key rendered the raw dotted key. It now falls through.
//
// ★ AND THE humanizeKey FALLBACK IS GONE — the one deliberate departure from 4c05e589, which kept it.
// It rendered a key's last segment title-cased, so a bare host showed "Upg100 Wall Intro" where a
// whole SENTENCE exists: wrong even in English, and untranslatable because it comes from the
// identifier rather than a catalogue. It was kept on the theory that these keys were not fully
// catalogued — I told regions-plugin exactly that, and it was FALSE: this file's three keys are
// present in all ELEVEN locales, 0 gaps (enumerated 2026-09-27; the only three, all string literals).
// ⚠ IT GUARDS A BARE HOST, NOT A MISSING KEY. Those are different failures and I had them confused.
// Product call taken by dx-shepherd (UPGHK, 2026-09-27) under the Driver's standing goal that the
// production maps be localized.
const t = useOptionalT('')
// ★ THE MARKUP IS COMPOSED HERE, so the CATALOGUE holds plain text (dx-shepherd UPGHTML, 2026-09-27).
// vue-i18n warns "[intlify] Detected HTML in '…'" twice per tab pass, and it is right to: a translated
// string carrying <strong> is the only one in this tree that does — I measured every en catalogue area
// and upg100WallIntro is 1 of 1, so this was not a convention, it was my outlier.
//
// ⚠ WHY THE OBVIOUS FIXES DO NOT APPLY HERE. The shepherd's suggestion — markup in the template, or
// <i18n-t> interpolation — assumes this profile renders the sentence. It does not: `intro` is a STRING
// PROP on the library PosterWall, rendered there with v-html (PosterWall.vue:955), and that component
// has no slots at all. So the markup has to arrive inside the string, and the only question is who
// builds the string. Building it HERE keeps the catalogue clean without a library change.
//
// ★ AND IT IS STRICTLY SAFER THAN WHAT IT REPLACES, which is the part worth keeping. v-html on a whole
// catalogue string TRUSTS THE CATALOGUE: any tag a translator (or a bad merge) puts in any of the 11
// locale files renders as markup. Composing here escapes each part and emits exactly one tag I control,
// so the trusted surface shrinks from "every translation of this key" to "this line".
//
// THREE PARTS, NOT A WRAPPER AROUND ONE, because the emphasis is not in the same place in every
// language. Measured across all 11 locales: each has exactly one <strong> and no other tags, and de, hi
// and zh carry real text AFTER the emphasised run (" beten.", " प्रार्थना करते हुए।", "為他們禱告。")
// where the others end on a full stop. A head+emphasis wrapper would have silently dropped those tails.
const escHtml = (s) => String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const intro = computed(() => {
// The three plain-text keys studio adds (rule 5B — the catalogue is theirs). Until they land, the
// single legacy key is still the source of truth, so this renders identically before AND after: no
// flag day, and my landing does not have to be ordered against theirs.
const a = t('messages.upg100WallIntroA')
const b = t('messages.upg100WallIntroEmph')
const c = t('messages.upg100WallIntroB')
const missing = (v, key) => !v || v === key
// ★ THE LEGACY upg100WallIntro FALLBACK IS GONE (2026-09-27). It existed for exactly one window: the
// three plain-text keys were landing in studio's catalogue while this consumer was already written,
// so the branch rendered the old single markup-bearing key until they arrived. They are on main in
// all eleven locales, so the branch is now DEAD CODE — and dead code that names a key keeps that key
// alive: studio cannot delete upg100WallIntro while this line reads it (their L13U measurement).
// A fallback whose condition can no longer be true is not safety, it is a reference.
// If A or Emph ever went missing the per-key fallback still yields English from the en block, and
// check:i18n-keys-exist fails the build before a reader could see it.
const tail = missing(c, 'messages.upg100WallIntroB') ? '' : escHtml(c)
return `${escHtml(a)}<strong>${escHtml(b)}</strong>${tail}`
})
const heading = computed(() => t('messages.upg100WallHeading'))
const wallTag = computed(() => t('messages.upg100WallTag'))
</script>
<template>
<PosterWall
:items="items"
:group-by="(item) => regionFor(item.id)"
:instance-id="instanceId"
:is-dark="isDark"
:assets-base="assetsBase"
event-name="doxa-upg-100-list:show-on-map"
:heading="heading"
:tag="wallTag"
:intro="intro"
unit="UPG"
item-count-key="wall.listCount"
:records="fetchListRecords"
:iso3-names="iso3Names"
:sheet="zoneSheet"
:card-stocks="true"
poster-action="print"
preview-action="print"
:hide-prayer-list-when-soon="true"
download-prefix="doxa-upg-100"
:columns-control="true"
/>
</template>