<dxm-poster-section>: one poster section, any poster type, as a reusable element

The section the Country Posters tab draws (a collapsible region head + its poster cards) is now usable on its
own, for any poster set, by parameters:
- library/components/poster/posterTypes.js: a TYPE REGISTRY. A type is an adapter (items for a scope, the
  section's title, the print actions). The library registers 'country' (library data + the shared sheet path,
  so a withheld whole-country poster stays withheld); plugins register their own types from their own code
  (UPG 100 lists, regions, continents later). No plugin code in the library.
- 'country' scopes: a region key in any spelling. DX Life's /regions slug ('latin-america-and-caribbean'),
  the API's value, the wall's slug and the English name all reduce to one canonical key.
- PosterSectionView.vue: one section, drawn by a PosterWall in a new SECTION-ONLY mode (no wall head; off by
  default, so every existing wall renders as before), embedded so the page scrolls it.
- <dxm-poster-section type scope collapsible collapsed locale assets-base api-base-url is-dark>: the
  attributes map onto the standard element's profile-config (posterSectionConfig.js, pure), so it keeps the
  per-instance i18n and store. registerPosterSectionElement() from any bundle; Country Posters ships it first.
- country-posters/poster-section: a page showing two sections (Africa open, Oceania collapsed).
- poster.sectionNone (11 locales): an unknown type or an empty scope says so, never a blank.
check:poster-section (gates:pre) + self-test: the registry, DX Life's 8 scopes covering every country once,
withheld India, the attribute mapping, and PosterSection.vue injecting every name its template uses; 3 poisons.
The known collapse-while-scrolled landing is left exactly as it is.
This commit is contained in:
Doxa Maps 2026-10-02 06:56:48 +00:00
parent 194006412f
commit a95b88ed65
22 changed files with 361 additions and 12 deletions

View file

@ -0,0 +1,65 @@
<script setup>
/**
* PosterSectionView.vue — ONE poster section on its own: the profile behind <dxm-poster-section>.
* It reads its settings from profile-config (type, scope, collapsible, collapsed, locale, assetsBase), finds the
* poster TYPE in posterTypes.js, and draws a PosterWall in section-only mode: the same section a wall draws (the
* same PosterSection, the same print actions and overlay), with no wall head around it.
*/
import { computed, inject, ref, watch } from 'vue'
import PosterWall from './PosterWall.vue'
import { posterType } from './posterTypes.js'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
const profileConfig = inject('profileConfig', ref(null))
const cfg = computed(() => profileConfig.value || {})
const t = useOptionalT('')
const typeName = computed(() => cfg.value.type || 'country')
const adapter = ref(null)
const unknownType = ref(false)
watch(typeName, async (name) => {
const a = await posterType(name)
adapter.value = a; unknownType.value = !a
}, { immediate: true })
const lang = () => cfg.value.locale || getRuntimeLocale() || detectLocale() || 'en'
const assetsBase = computed(() => cfg.value.assetsBase || './assets')
const items = computed(() => (adapter.value ? adapter.value.items(cfg.value.scope, lang()) : []))
const title = computed(() => (adapter.value ? adapter.value.title(cfg.value.scope, t) : ''))
const actions = computed(() => (adapter.value
? adapter.value.actions({ assetsBase: () => assetsBase.value, apiBase: () => cfg.value.apiBaseUrl || getApiBaseUrl(), lang, profileConfig, t })
: null))
const collapsible = computed(() => cfg.value.collapsible !== false)
const startCollapsed = computed(() => cfg.value.collapsed === true)
const groupBy = () => title.value
const collapsedBy = () => startCollapsed.value
const instanceId = computed(() => `poster-section-${typeName.value}-${String(cfg.value.scope || 'all')}`)
// Nothing to show still SAYS so (an unknown type or scope never renders as a blank).
const noneLine = computed(() => t('poster.sectionNone', { scope: String(cfg.value.scope || '') }))
</script>
<template>
<p v-if="unknownType || (adapter && !items.length)" class="dxm-ps-none" role="status">{{ noneLine }}</p>
<!-- embedded: an in-flow block the PAGE scrolls (a wall alone is a full-pane scroller), so sections stack on a
page and sit inline under a host's own heading. -->
<PosterWall v-else-if="adapter && actions"
section-only
embedded
:items="items"
:group-by="groupBy"
:collapsible="collapsible"
:collapsed-by="collapsedBy"
:instance-id="instanceId"
:is-dark="cfg.isDark === true"
:assets-base="assetsBase"
:event-name="actions.eventName"
:unit="actions.unit"
:item-count-key="actions.itemCountKey"
:records="actions.records"
:sheet="actions.sheet"
:unsupported="actions.unsupported"
:card-stocks="actions.cardStocks"
:download-prefix="actions.downloadPrefix"
/>
</template>

View file

@ -208,6 +208,9 @@ const props = defineProps({
// to count n rather than the section's card count, or null for that section to keep the default. The Country
// Posters India section holds India's own card and one per state and union territory, which are not countries.
sectionItemCountKey: { type: Function, default: null },
// SECTION-ONLY: the wall's own head (heading, tag, intro) is not drawn, only its section(s): a standalone poster
// section (<dxm-poster-section>). Off by default, so every existing wall renders exactly as before.
sectionOnly: { type: Boolean, default: false },
})
const emit = defineEmits(['header-action'])
@ -1342,9 +1345,11 @@ provide(POSTER_WALL_CTX, {
<template>
<div ref="wallEl" class="wall" :class="{ 'is-dark': isDark, 'wall--embedded': embedded }">
<template v-if="!sectionOnly">
<h2 class="wall-h">{{ heading }}</h2>
<div v-if="tag" class="wall-tag">{{ tag }}</div>
<p v-if="intro" class="wall-sub" v-html="intro"></p>
</template>
<!-- Each section stacks ABOVE the ones after it (z-index decreasing down the wall; see .sec), so the LAST
poster's open menu, which hangs past its section's end, sits over the next section's header. -->

View file

@ -0,0 +1,32 @@
/**
* posterTypes.js — the POSTER TYPES a poster section can show, by name.
*
* A type is an ADAPTER: what its posters are and how they print. The library registers 'country' (its data and
* its sheet path are library code); a plugin registers its own type from its own code (registerPosterType), so no
* plugin-specific code lives here. Loaded lazily: a page that shows only country posters never pulls another
* type's data.
*
* adapter = {
* items(scope, locale) → the cards for a scope (a region / continent / list key), [] when none
* title(scope, t) → the section's heading, in the reader's language
* actions({ assetsBase, apiBase, lang, profileConfig, t }) → the PosterWall props that print a card:
* { records, sheet, unsupported, unit, itemCountKey, eventName, downloadPrefix, cardStocks }
* }
*/
const LOADERS = new Map([
['country', () => import('./types/countryPosterType.js')],
])
const CACHE = new Map()
/** Register (or replace) a type: an adapter object, or a function that loads one (a dynamic import). */
export function registerPosterType(name, adapterOrLoader) {
LOADERS.set(name, typeof adapterOrLoader === 'function' ? adapterOrLoader : () => Promise.resolve({ default: adapterOrLoader }))
CACHE.delete(name)
}
/** The adapter for a type, or null for a type nobody registered. */
export function posterType(name) {
if (!LOADERS.has(name)) return Promise.resolve(null)
if (!CACHE.has(name)) CACHE.set(name, LOADERS.get(name)().then((m) => m.default || m))
return CACHE.get(name)
}
export const posterTypeNames = () => [...LOADERS.keys()]

View file

@ -0,0 +1,49 @@
/**
* The 'country' poster type: one poster per country, grouped by its WAGF region. Exactly what the Country Posters
* wall shows for a region: the same data (country-posters.json), the same records provider, the same sheet path
* (countrySheet.js, so a withheld country stays withheld), the same region headings.
* scope = a region key in ANY of its spellings: DX Life's /regions slug ('latin-america-and-caribbean'), the API's
* value ('latin_america_&_caribbean'), the wall's slug ('latin_america_&_caribbean') or the English name. They all
* reduce to one canonical key (DX Life's slugify: no diacritics, lowercase, "&" → "and", non-alphanumerics → "-");
* a country with no region is 'other'.
*/
import data from '@map/data/country-posters.json'
import { makeCountrySheet, sheetWithheld } from '@map/packet/countrySheet.js'
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
import { keyedError } from '@map/i18n/keyedError.js'
import { countryName } from '@map/utils/countryName.js'
const slug = (s) => String(s || '').toLowerCase().replace(/\s+/g, '_') // the wall's slug: its i18n heading key
export const regionKey = (s) => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
.replace(/['’.]/g, '').replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'other'
const TOO_MANY = 'Too many people groups to fit on one poster legibly — a solution is in progress.'
const inScope = (scope) => (c) => regionKey(c.region) === regionKey(scope)
export default {
scopes: () => [...new Set(data.countries.map((c) => regionKey(c.region)))],
items(scope, locale) {
const rows = data.countries.filter(inScope(scope))
if (/^en\b/i.test(locale || 'en')) return rows
return rows.map((c) => ({ ...c, label: countryName(c.iso3, locale) || c.label }))
},
title(scope, t) {
const region = (data.countries.find(inScope(scope)) || {}).region || String(scope || '')
if (!region || region === 'Other') return t('dashboard.other')
const key = `dashboard.wagfRegion.${slug(region)}`
const v = t(key)
return v && v !== key ? v : region
},
actions({ assetsBase, apiBase, lang, profileConfig, t }) {
const recordsFor = createPeopleGroupRecords({ apiBase, lang, profileConfig })
const sheet = makeCountrySheet({ records: recordsFor, assetsBase, countries: data.countries })
const byId = new Map(data.countries.map((c) => [c.id, c]))
return {
// a withheld whole-country poster withholds its list, packet and cards too (countrySheet.js WITHHELD)
records: (c) => (sheetWithheld(c) ? Promise.reject(keyedError('messages.countryPosterTooMany', TOO_MANY)) : recordsFor({ type: 'country', values: [c.iso3] })),
// the sheet gets the data's own record (English, the series index by id), as on the wall
sheet: (card, format) => sheet(byId.get(card.id) || card, format),
unsupported: (c) => (sheetWithheld(c) || c.unsupportedReason ? t('messages.countryPosterTooMany') : null),
unit: 'UPG', itemCountKey: 'regions.countryCount', eventName: 'doxa-country-posters:show-on-map', downloadPrefix: 'doxa-country', cardStocks: true,
}
},
}

View file

@ -0,0 +1,14 @@
// <dxm-poster-section>'s attributes → its profile-config. Pure (no Vue), so it is testable on its own.
export const ATTRS = ['type', 'scope', 'collapsible', 'collapsed', 'locale', 'assets-base', 'api-base-url', 'is-dark']
export const TAG = 'dxm-poster-section'
export function posterSectionConfig(el) {
const a = (n) => el.getAttribute(n)
const flag = (n, dflt) => (el.hasAttribute(n) ? a(n) !== 'false' : dflt)
const cfg = { type: a('type') || 'country', scope: a('scope') || '', collapsible: flag('collapsible', true), collapsed: flag('collapsed', false), isDark: flag('is-dark', false) }
if (a('locale')) cfg.locale = a('locale')
if (a('assets-base')) cfg.assetsBase = a('assets-base')
if (a('api-base-url')) cfg.apiBaseUrl = a('api-base-url')
return cfg
}

View file

@ -0,0 +1,40 @@
/**
* posterSectionElement.js — registers <dxm-poster-section>: ONE poster section, any poster type, as an element.
*
* <dxm-poster-section type="country" scope="africa" locale="ar"></dxm-poster-section>
*
* Attributes: type (default "country"), scope (a region / continent / list key), collapsible (default on;
* collapsible="false" turns it off), collapsed (start shut), locale, assets-base, api-base-url, is-dark.
* They are mapped into the standard element's profile-config, so the element keeps defineMapElement's per-instance
* i18n and store, and the profile (PosterSectionView.vue) reads them like every other profile reads its config.
* Any bundle can call registerPosterSectionElement(); a second call is a no-op.
*/
import { defineMapElement } from './defineMapElement.js'
import { ATTRS, TAG, posterSectionConfig } from './posterSectionConfig.js'
export { posterSectionConfig }
export function registerPosterSectionElement(tag = TAG) {
if (typeof customElements === 'undefined' || customElements.get(tag)) return
const Base = defineMapElement({ profileModules: { './poster-section.vue': () => import('../components/poster/PosterSectionView.vue') } })
class PosterSectionElement extends Base {
connectedCallback() {
this.syncConfig()
if (!this.attrWatch) {
this.attrWatch = new MutationObserver(() => this.syncConfig())
this.attrWatch.observe(this, { attributes: true, attributeFilter: ATTRS })
}
super.connectedCallback()
}
disconnectedCallback() {
if (this.attrWatch) { this.attrWatch.disconnect(); this.attrWatch = null }
super.disconnectedCallback()
}
syncConfig() {
const next = JSON.stringify(posterSectionConfig(this))
if (this.getAttribute('profile-config') !== next) this.setAttribute('profile-config', next)
}
}
customElements.define(tag, PosterSectionElement)
}
export default registerPosterSectionElement

View file

@ -41,5 +41,6 @@
"optional": "(اختياري)",
"phoneLimit": "على الهاتف، أكبر المقاسات غير متاحة: لا يستطيع الهاتف رسم ملصق بهذا الحجم. استخدم حاسوبًا لها.",
"phoneLimitAll": "يتطلّب هذا حاسوبًا مكتبيًا أو محمولًا.",
"glue_here": "الصق هنا"
"glue_here": "الصق هنا",
"sectionNone": "لا توجد ملصقات لـ {scope}."
}

View file

@ -41,5 +41,6 @@
"optional": "(optional)",
"phoneLimit": "Auf dem Handy sind die größten Formate ausgeschaltet: Ein Handy kann kein so großes Poster zeichnen. Nutzen Sie dafür einen Computer.",
"phoneLimitAll": "Dafür ist ein Desktop- oder Laptop-Computer nötig.",
"glue_here": "hier kleben"
"glue_here": "hier kleben",
"sectionNone": "Keine Poster für {scope}."
}

View file

@ -41,5 +41,6 @@
"optional": "(optional)",
"phoneLimit": "On a phone, the largest sizes are off: a phone can't draw a poster that big. Use a computer for them.",
"phoneLimitAll": "This needs a desktop or laptop computer.",
"glue_here": "glue here"
"glue_here": "glue here",
"sectionNone": "No posters for {scope}."
}

View file

@ -41,5 +41,6 @@
"optional": "(opcional)",
"phoneLimit": "En un teléfono, los tamaños más grandes están desactivados: un teléfono no puede dibujar un póster tan grande. Usa un ordenador para ellos.",
"phoneLimitAll": "Esto requiere una computadora de escritorio o portátil.",
"glue_here": "pegar aquí"
"glue_here": "pegar aquí",
"sectionNone": "No hay pósteres para {scope}."
}

View file

@ -41,5 +41,6 @@
"optional": "(facultatif)",
"phoneLimit": "Sur un téléphone, les plus grands formats sont désactivés : un téléphone ne peut pas dessiner une affiche aussi grande. Utilisez un ordinateur pour ceux-là.",
"phoneLimitAll": "Cela nécessite un ordinateur de bureau ou un portable.",
"glue_here": "coller ici"
"glue_here": "coller ici",
"sectionNone": "Aucune affiche pour {scope}."
}

View file

@ -41,5 +41,6 @@
"optional": "(वैकल्पिक)",
"phoneLimit": "फ़ोन पर सबसे बड़े आकार बंद हैं: फ़ोन इतना बड़ा पोस्टर नहीं बना सकता। उनके लिए कंप्यूटर का उपयोग करें।",
"phoneLimitAll": "इसके लिए डेस्कटॉप या लैपटॉप कंप्यूटर चाहिए।",
"glue_here": "यहाँ चिपकाएँ"
"glue_here": "यहाँ चिपकाएँ",
"sectionNone": "{scope} के लिए कोई पोस्टर नहीं।"
}

View file

@ -41,5 +41,6 @@
"optional": "(facoltativo)",
"phoneLimit": "Sul telefono i formati più grandi sono disattivati: un telefono non può disegnare un poster così grande. Per quelli usa un computer.",
"phoneLimitAll": "Serve un computer desktop o portatile.",
"glue_here": "incollare qui"
"glue_here": "incollare qui",
"sectionNone": "Nessun poster per {scope}."
}

View file

@ -41,5 +41,6 @@
"optional": "(opcional)",
"phoneLimit": "No telefone, os tamanhos maiores ficam desativados: um telefone não consegue desenhar um cartaz tão grande. Use um computador para eles.",
"phoneLimitAll": "Isto requer um computador de mesa ou portátil.",
"glue_here": "colar aqui"
"glue_here": "colar aqui",
"sectionNone": "Nenhum pôster para {scope}."
}

View file

@ -41,5 +41,6 @@
"optional": "(opțional)",
"phoneLimit": "Pe telefon, cele mai mari dimensiuni sunt dezactivate: un telefon nu poate desena un poster atât de mare. Folosește un calculator pentru ele.",
"phoneLimitAll": "Pentru aceasta este nevoie de un computer desktop sau laptop.",
"glue_here": "lipiți aici"
"glue_here": "lipiți aici",
"sectionNone": "Niciun poster pentru {scope}."
}

View file

@ -41,5 +41,6 @@
"optional": "(необязательно)",
"phoneLimit": "На телефоне самые большие размеры отключены: телефон не может нарисовать такой большой плакат. Используйте для них компьютер.",
"phoneLimitAll": "Для этого нужен настольный компьютер или ноутбук.",
"glue_here": "клеить здесь"
"glue_here": "клеить здесь",
"sectionNone": "Нет плакатов для {scope}."
}

View file

@ -41,5 +41,6 @@
"optional": "(可选)",
"phoneLimit": "在手机上,最大的尺寸已关闭:手机无法绘制这么大的海报。请使用电脑制作这些尺寸。",
"phoneLimitAll": "这需要台式电脑或笔记本电脑。",
"glue_here": "在此粘贴"
"glue_here": "在此粘贴",
"sectionNone": "{scope} 没有海报。"
}

View file

@ -351,7 +351,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -418,6 +418,8 @@
"check:shell-header": "node internal/check-shell-header.mjs",
"check:side-lanes": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs",
"check:side-lanes:self-test": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs --self-test",
"check:poster-section": "node plugin-designs/country-posters/scripts/check-poster-section.mjs",
"check:poster-section:self-test": "node plugin-designs/country-posters/scripts/check-poster-section.mjs --self-test",
"check:side-lane-photo-pixels": "node plugin-designs/country-posters/scripts/check-side-lane-photo-pixels.mjs",
"check:side-lane-photo-pixels:self-test": "node plugin-designs/country-posters/scripts/check-side-lane-photo-pixels.mjs --self-test"
},
@ -687,6 +689,8 @@
"check:shell-header": "a header-off shell has ONE way to its tabs and ONE language menu, in the right place for the width: VerticalTabsLayout renders the phone bar (☰ + #phone-end) only when narrow AND header-off, #rail-end only when wide AND header-off, and no corner circle; ShellLanguageMenu's pick dispatches exactly one cancelable, composed, bubbling doxa-language-request (detail.language), does not switch when a host cancels, and writes ?lang= only on a tree-owned page; every switcher language has a catalogue and the current language is always listed. Headless (Vite SSR). Run the `check:shell-header:self-test` script for the NEGATIVE test (11 planted defects)",
"check:side-lanes": "the large side-lane sheets (60x96, 66x66, 36x60) through the real poster-render.js, on a SEEDED worst case (855 groups, stacked duplicates, tight clusters, real and placeholder photos): every name drawn exactly once, leader crossings 0 (re-measured from the SVG, and equal to the renderer's own count), no overflow rows, photos only where a real one exists and the format allows them.",
"check:side-lanes:self-test": "a poster-render.js copy WITHOUT the lane clamp must make check:side-lanes' crossing arm FAIL on the same seeded data (it does: 36 crossings), so the arm is shown able to fail.",
"check:poster-section": "the reusable poster section (<dxm-poster-section>): the type registry has 'country'; DX Life's /regions slugs (and every other spelling of a region) each show their countries, every country once; a withheld whole-country poster stays withheld through the type; the element's attributes map to its config; PosterSection.vue injects every name its template uses.",
"check:poster-section:self-test": "a registry without 'country', a region key without \"&\" → \"and\", and a section that uses a name it does not inject must each make check:poster-section fail.",
"check:side-lane-photo-pixels": "BROWSER gate (browser seat; elsewhere 75 NOT MEASURED): the 60x96 side-lane sheet rasterised by poster-raster.js, measured in pixels at every photo slot. A data: photo draws; every photo on its own host stays flat, never the broken-image glyph an external <image> rasterises as.",
"check:side-lane-photo-pixels:self-test": "the same raster WITHOUT stripExternalImages must leave the external photo slots NOT flat (the glyph), else the gate proves nothing."
},

View file

@ -1,4 +1,5 @@
import { defineMapElement } from '@map/elements/defineMapElement.js'
import { registerPosterSectionElement } from '@map/elements/posterSectionElement.js'
/**
* plugin-designs/country-posters/index.js
*
@ -60,4 +61,7 @@ if (typeof customElements !== 'undefined') {
}
}
// <dxm-poster-section>: one poster section (any poster type), the reusable element this bundle ships first.
registerPosterSectionElement()
export default CountryPostersElement

View file

@ -0,0 +1,5 @@
{
"type": "single",
"label": "Country Posters — one poster section (<dxm-poster-section>)",
"note": "Shows the reusable <dxm-poster-section> element: one region's posters, collapsible, with the same cards and print options as the wall. Attributes: type (country), scope (a region key, e.g. africa), collapsible, collapsed, locale."
}

View file

@ -0,0 +1,21 @@
<script setup>
/**
* poster-section.vue — the reusable <dxm-poster-section> element, shown on its own page: two regions, each ONE
* section (the same cards and print options as the Country Posters wall), one collapsible and open, one starting
* shut. The element is the library's (registered by this bundle); this page only places it.
*/
import { computed, h, inject, ref } from 'vue'
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
const profileConfig = inject('profileConfig', ref(null))
const cfg = computed(() => profileConfig.value || {})
const locale = computed(() => cfg.value.locale || getRuntimeLocale() || detectLocale() || 'en')
const section = (scope, extra = {}) => h('dxm-poster-section', {
type: 'country', scope, locale: locale.value, 'assets-base': cfg.value.assetsBase || './assets', ...extra,
})
const Page = () => h('div', { class: 'ps-demo' }, [section(cfg.value.scope || 'africa'), section('oceania', { collapsed: '' })])
</script>
<template>
<Page />
</template>

View file

@ -0,0 +1,99 @@
#!/usr/bin/env node
// check:poster-section — the reusable poster section (<dxm-poster-section>) and the lift behind it.
// A. the TYPE REGISTRY: 'country' is registered, an unknown type is null, a plugin can register its own.
// B. SCOPES: DX Life's /regions slugs (africa … oceania, other) each resolve; every spelling of a region (its
// slug, its API value, its English name) resolves to the same countries; the scopes cover all countries once.
// C. a WITHHELD whole-country poster stays withheld through the type (the card's sentence; its rows rejected,
// no records fetched).
// D. the ELEMENT's attributes map to its config (collapsible on by default, collapsible="false", collapsed).
// E. THE LIFT: every name PosterSection.vue's template uses is injected from the wall (a section never reads
// something its wall does not provide).
// --self-test: a registry without 'country', a region key without "&" → "and", and a section template that uses a
// name it does not inject must each FAIL. Loaded through Vite (the @map alias), like check:poster-wall-host-seams.
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const DXL = ['africa', 'asia', 'europe', 'latin-america-and-caribbean', 'middle-east', 'north-america-and-non-spanish-caribbean', 'oceania', 'other']
async function arms({ types, country, element, sectionSrc }) {
const fails = []
// A
if (!types.posterTypeNames().includes('country')) fails.push("A: the registry has no 'country' type")
if (await types.posterType('no-such-type') !== null) fails.push('A: an unknown type is not null')
types.registerPosterType('gate-fixture', { items: () => [{ id: 'x' }], title: () => 'X', actions: () => ({}) })
if (!(await types.posterType('gate-fixture'))) fails.push('A: a registered type cannot be found')
// B
const data = JSON.parse(readFileSync(join(ROOT, 'library/data/country-posters.json'), 'utf8')).countries
const seen = new Map()
for (const k of DXL) {
const ids = country.items(k, 'en').map((c) => c.id)
if (!ids.length) fails.push(`B: scope "${k}" (a DX Life /regions slug) shows no countries`)
for (const id of ids) seen.set(id, (seen.get(id) || 0) + 1)
}
const once = data.filter((c) => seen.get(c.id) === 1).length
if (once !== data.length) fails.push(`B: the scopes cover ${once} of ${data.length} countries exactly once`)
for (const [a, b, c] of [['latin-america-and-caribbean', 'latin_america_&_caribbean', 'Latin America & Caribbean'], ['middle-east', 'middle_east', 'Middle East']]) {
const s = (x) => country.items(x, 'en').map((r) => r.id).join()
if (!(s(a) && s(a) === s(b) && s(b) === s(c))) fails.push(`B: "${a}" / "${b}" / "${c}" do not resolve to the same countries`)
}
// C
const ind = country.items('asia', 'en').find((c) => c.iso3 === 'IND')
if (!ind) fails.push('C: India is not in the asia scope')
else {
let fetched = 0
const act = country.actions({ assetsBase: () => '', apiBase: () => { fetched++; return '' }, lang: () => 'en', profileConfig: null, t: (k) => k })
if (act.unsupported(ind) !== 'messages.countryPosterTooMany') fails.push("C: withheld India's card does not show messages.countryPosterTooMany")
let why = ''
try { await act.records(ind); why = 'resolved' } catch (e) { why = e && (e.i18nKey || e.message) }
if (!/countryPosterTooMany/.test(String(why))) fails.push(`C: withheld India's rows are not withheld (${why})`)
if (fetched) fails.push('C: withheld India fetched records')
}
// D
const fake = (attrs) => ({ getAttribute: (n) => (n in attrs ? attrs[n] : null), hasAttribute: (n) => n in attrs })
const d0 = element.posterSectionConfig(fake({ scope: 'africa' }))
if (!(d0.type === 'country' && d0.scope === 'africa' && d0.collapsible === true && d0.collapsed === false)) fails.push(`D: defaults wrong ${JSON.stringify(d0)}`)
if (element.posterSectionConfig(fake({ collapsible: 'false' })).collapsible !== false) fails.push('D: collapsible="false" does not turn collapsing off')
if (element.posterSectionConfig(fake({ collapsed: '' })).collapsed !== true) fails.push('D: collapsed does not start the section shut')
// E
const tpl = (sectionSrc.match(/<template>([\s\S]*)<\/template>\s*$/) || ['', ''])[1].replace(/<!--[\s\S]*?-->/g, '')
const injected = new Set(((sectionSrc.match(/const \{([\s\S]*?)\} = inject\(POSTER_WALL_CTX\)/) || ['', ''])[1]).split(/[\s,]+/).filter(Boolean))
const used = new Set()
for (const m of tpl.matchAll(/(?:[:@]|v-[a-z.-]+)[\w.-]*="([^"]*)"|\{\{([^}]*)\}\}/g)) {
for (const e of [m[1], m[2]]) { if (!e) continue
for (const id of e.replace(/'[^']*'|`[^`]*`/g, '').matchAll(/(?<![\w.$])([A-Za-z_$][\w$]*)/g)) used.add(id[1]) }
}
const local = new Set(['sec', 'z', 'item', 'n', 's', 'v', 'el', 'true', 'false', 'null', 'String', 'Number', '$event', 'in', 'label', 'zIndex'])
const missing = [...used].filter((x) => !local.has(x) && !injected.has(x))
if (missing.length) fails.push(`E: PosterSection.vue uses names it does not inject: ${missing.join(', ')}`)
return fails
}
const { createServer } = await import('vite')
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom', server: { middlewareMode: true, hmr: false, ws: false }, resolve: { alias: { '@map': join(ROOT, 'library') } } })
let code = 0
try {
const load = (p) => server.ssrLoadModule(join(ROOT, p))
const types = await load('library/components/poster/posterTypes.js')
const country = (await load('library/components/poster/types/countryPosterType.js')).default
const element = await load('library/elements/posterSectionConfig.js')
const sectionSrc = readFileSync(join(ROOT, 'library/components/poster/PosterSection.vue'), 'utf8')
const real = { types, country, element, sectionSrc }
if (process.argv.includes('--self-test')) {
const poisons = [
["a registry without 'country'", { ...real, types: { ...types, posterTypeNames: () => [] } }, /^A:/],
['a region key without "&" → "and"', { ...real, country: { ...country, items: (s, l) => (/-and-/.test(String(s)) ? [] : country.items(s, l)) } }, /^B:/],
['a section that uses a name it does not inject', { ...real, sectionSrc: sectionSrc.replace('</template>', '{{ notProvided }}</template>') }, /^E:/],
]
for (const [name, inp, rx] of poisons) {
const f = await arms(inp); const ok = f.some((x) => rx.test(x))
console.log(ok ? `✓ caught: ${name} -> ${f.find((x) => rx.test(x))}` : `✗ MISSED: ${name}`)
if (!ok) code = 1
}
} else {
const f = await arms(real)
if (f.length) { f.forEach((x) => console.log('FAIL ' + x)); code = 1 }
else console.log(`PASS check:poster-section — the 'country' type; ${DXL.length} DX Life scopes cover every country once (and their other spellings); withheld India stays withheld; the element's attributes; PosterSection injects every name it uses`)
}
} finally { await server.close() }
process.exit(code)