previewUnreleased() is import.meta.env && import.meta.env.DEV && globalThis.DXM_PREVIEW_UNRELEASED === true, and the India tab uses the same expression inline, so every BUILT bundle compiles both away. A page, an embed or a reader's console setting the global can no longer print the withheld poster from production; only the Vite dev server (the pilot's Plugin Studio, whose element bootstrap sets it) previews it. posterWithheld()/largeSheet() take the preview state as an optional argument, so a gate can test an open preview directly. check:country-sheet-one-path arm G: a global alone does NOT open the preview outside DEV; withheld by default; an open preview prints 66x66; the India tab is gated on the DEV read; and the built tree mentions the flag 0 times (the stale ungated build failed this check, as it should). A poison for each.
291 lines
16 KiB
Vue
291 lines
16 KiB
Vue
<script setup>
|
|
/**
|
|
* country-posters-mfe.vue — Country Posters as a TWO-TAB element.
|
|
*
|
|
* @element country-posters-map
|
|
* @description Country Posters as ONE two-tab element — the countries map and the printable
|
|
* poster wall behind a single view switcher, structured like the UPG-100 list. The switcher is
|
|
* the library's shared ViewSelect, not a third hand-rolled copy.
|
|
*
|
|
* Country Posters has two tabs, structured like the
|
|
* UPG-100 list.
|
|
*
|
|
* ★ THE CONTROL IS SHARED, NOT COPIED. That is the whole point of the card: this
|
|
* is the THIRD place the SELECT VIEW switcher is needed, and it uses the library's
|
|
* ViewSelect.vue — the same component upg-100-mfe and regions-mfe render. A third
|
|
* hand-rolled copy is exactly what library/components/ViewSelect.vue was extracted
|
|
* to prevent (its own header records that a fourth divergent copy is not what the
|
|
* library is for).
|
|
*
|
|
* SAME SHAPE AS upg-100-mfe, deliberately — index.js already says this bundle is
|
|
* "the same shape as my-upg-100-list", and copying its structure is what makes the
|
|
* two elements behave identically for a reader who moves between them:
|
|
* • the pane-wrapper layout (buildPaneColumn + PANE_GRID_STYLES)
|
|
* • each pane keeps its own ProfileLoader/Pinia
|
|
* • one ShareButton in the header, per the recorded share policy
|
|
* (check:share-policy) — the MAP pane must not grow a second one
|
|
*
|
|
* LIST FIRST, MAP SECOND. The principle already applied in upg-100-mfe: a map need
|
|
* not be the primary view — a list or a grid can be, with the map secondary and
|
|
* reached as a transition from it. The poster wall
|
|
* is the concrete artefact a newcomer can act on; the map is where it is placed.
|
|
*/
|
|
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue'
|
|
import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js'
|
|
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
|
import ViewSelect from '@map/components/ViewSelect.vue'
|
|
import { countryName } from '@map/utils/countryName.js'
|
|
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
|
|
import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import enTabs from '@map/i18n/locales/en/tabs.json'
|
|
|
|
// The pane labels are the chrome a reader sees FIRST, and they were English literals while the
|
|
// panes inside translated (the upg-100-mfe finding). GUARDED: useI18n() throws on a host that never
|
|
// installed vue-i18n, so the English catalog is the fallback, never a blank or a dotted key.
|
|
let t = (k) => enTabs[String(k).replace(/^tabs\./, '')] || String(k)
|
|
try { const i18n = useI18n(); t = (k) => { const v = i18n.t(k); return v === k ? (enTabs[String(k).replace(/^tabs\./, '')] || k) : v } } catch (_) { /* no i18n on this host */ }
|
|
|
|
const cfg = inject('profileConfig', ref(null))
|
|
const uiStore = inject('uiStore', null)
|
|
const isDark = computed(() => !!uiStore?.isDark)
|
|
|
|
const TAG = 'country-posters-map'
|
|
// One instanceId shared by BOTH panes, so a scoped cross-pane event addresses THIS
|
|
// embed even when two Country Posters elements sit on the same page.
|
|
const instanceId = computed(() => cfg.value?.instanceId || 'country-posters-mfe')
|
|
// The map pane needs the Mapbox token the host page was given; the wall does not.
|
|
const tk = computed(() => cfg.value?.tk || '')
|
|
|
|
const spec = computed(() => ({
|
|
id: 'country-posters-mfe',
|
|
tag: TAG,
|
|
// pinHint:false reaches the PANES from here, not from their own meta. A child's
|
|
// meta configures only its own LEAF page; nested in this composite each pane is
|
|
// handed the config built below, and PinHint.vue reads pinHint off the INJECTED
|
|
// profileConfig — so without this the coach-mark reappears over the cloned map.
|
|
config: { profile: 'country-poster-wall', instanceId: instanceId.value, pinHint: false },
|
|
// ORDER LIVES IN at[0], NOT IN THIS ARRAY'S ORDER. buildPaneColumn SORTS by at[0]
|
|
// while ViewSelect renders this array VERBATIM, so the two must agree or the tab
|
|
// strip desynchronises from the scroll column.
|
|
panes: [
|
|
{ id: 'wall', label: t('tabs.countryPosters'), at: [0, 0], config: { profile: 'country-poster-wall' } },
|
|
// the same DEV-gated read as countrySheet.js previewUnreleased(), INLINE so every build drops the pane entirely
|
|
...(import.meta.env && import.meta.env.DEV && globalThis.DXM_PREVIEW_UNRELEASED === true ? [INDIA_PANE] : []),
|
|
{ id: 'map', label: t('tabs.map'), at: [2, 0], config: { profile: 'countries-map' } },
|
|
],
|
|
// The switcher lives in this element's own header, so the layout's "… below ↓"
|
|
// pill would be a second, redundant control (same call upg-100-mfe makes).
|
|
paneHint: false,
|
|
}))
|
|
|
|
// INDIA: the whole-India poster (66x66 by default) on its own tab, the same wall filtered to India, labelled with
|
|
// India's own name in the reader's language (Intl). Shown ONLY in a preview context (countrySheet.js
|
|
// previewUnreleased(): the Plugin Studio on the pilot), because the poster is not released; everywhere else the tab
|
|
// is absent and India's card keeps the withheld sentence.
|
|
const INDIA_PANE = { id: 'india', label: countryName('IND', getRuntimeLocale() || detectLocale() || 'en') || 'India', at: [1, 0], config: { profile: 'country-poster-wall', countries: ['IND'] } }
|
|
|
|
const stage = ref(null)
|
|
// DERIVED from the pane order, never a hardcoded id — the same trap upg-100-mfe
|
|
// records: `ref('map')` silently became wrong the moment the panes were reordered.
|
|
const firstPaneId = computed(() => {
|
|
const col = spec.value.panes
|
|
.filter((p) => (p.at?.[1] ?? 0) === 0)
|
|
.sort((a, b) => (a.at?.[0] ?? 0) - (b.at?.[0] ?? 0))
|
|
return col[0]?.id || 'wall'
|
|
})
|
|
const viewRaw = ref(null)
|
|
const view = computed({
|
|
get: () => viewRaw.value || firstPaneId.value,
|
|
set: (v) => { viewRaw.value = v },
|
|
})
|
|
let column = null
|
|
let offScroll = null
|
|
|
|
// PAGE-LEVEL config keys a pane INHERITS from the host page. A cap-4 composite
|
|
// nests children as custom elements in THIS document, so anything the generator
|
|
// bakes once per page (where assets are served from, which backend, the theme) has
|
|
// to be forwarded explicitly or the child falls back to its own default — the
|
|
// BUG-UPG100MFE-ASSET-PATH-DEPTH-404S failure, where every preview 404'd because
|
|
// assetsBase never reached the consumer.
|
|
// Forwarded by KEY LIST, never by spreading the whole host config, so a pane's own
|
|
// `profile`/`instanceId` cannot be overwritten (pane.config is spread last).
|
|
// ★ 'provider' MUST travel. A composite that forwards the token but not the provider hands a
|
|
// partner who asked for MapLibre a Mapbox map in every nested pane -- the pane reads
|
|
// provider as undefined and falls back. Same shape of bug as the missing 'urlSync' here
|
|
// before it: the inner map cannot honour a key the shell never passed down.
|
|
// ★ 'dataSource' and 'tokenUrl' added 2026-09-26 (the same gap regions fixed in f0aa5ab3).
|
|
// dataSource is LIVE: countries-map reads it from its own profile-config (inject('dataSource'),
|
|
// countries-map.vue:392/622), nothing pinned it in spec.config or pane.config, and nothing publishes it
|
|
// page-wide, so a partner's dataSource on <country-posters-mfe> was dropped and the map fell back to
|
|
// sources.json's activeSource. tokenUrl is LATENT: the outer ProfileLoader fetches it and publishes
|
|
// the token page-wide, so the panes get a token today, but not the URL to re-ask it themselves.
|
|
// ★ The MAP's own view keys travel too (C11b ONE-CONFIG-HALF-WIRED, 2026-09-28): countries-map reads
|
|
// style / lightPreset / search / center / zoom / pitch / bearing / minZoom from its profile-config, and
|
|
// none of them was forwarded, so a partner who set a basemap, a night look, "no search" or a start view
|
|
// on <country-posters-mfe> got a working map at the DEFAULTS: it looked right and ignored them. The wall
|
|
// ignores these keys; pane.config still spreads last, so a pane's own profile/instanceId always wins.
|
|
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'tokenUrl', 'dataSource', 'locale', 'lang', 'isDark', 'qa', 'provider', 'list',
|
|
'style', 'lightPreset', 'search', 'center', 'zoom', 'pitch', 'bearing', 'minZoom']
|
|
|
|
function paneElement(pane) {
|
|
const el = document.createElement(TAG)
|
|
const host = cfg.value || {}
|
|
const inherited = {}
|
|
for (const k of INHERITED_PAGE_KEYS) {
|
|
if (host[k] !== undefined && host[k] !== null && host[k] !== '') inherited[k] = host[k]
|
|
}
|
|
// `lang` is the alias the page may carry; children read `locale`.
|
|
if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang
|
|
const pcfg = { ...inherited, ...spec.value.config, ...pane.config }
|
|
if (tk.value) pcfg.tk = tk.value
|
|
el.setAttribute('profile-config', JSON.stringify(pcfg))
|
|
return el
|
|
}
|
|
|
|
// `inert` on every pane except the active one: the same contract as upg-100-mfe (4642e78d). The panes
|
|
// are stacked in one scroll-snap column, so the hidden pane is still full-size geometry; inert tells
|
|
// PosterWall (67e9ede5) the wall is out of view so it forgets its shown card, and a second
|
|
// "Show on map" after Map -> back SHOWS the country again instead of toggling it off.
|
|
// inert, not display:none: hiding would tear down the live map.
|
|
function markActivePane(activeId) {
|
|
if (!column || !activeId) return
|
|
for (const pane of column.panes) {
|
|
const el = column.mountEl(pane.id)
|
|
if (!el) continue
|
|
if (pane.id === activeId) el.removeAttribute('inert')
|
|
else el.setAttribute('inert', '')
|
|
}
|
|
}
|
|
|
|
function go(id) {
|
|
view.value = id
|
|
column?.scrollTo(id)
|
|
markActivePane(id) // now, not after the smooth scroll settles
|
|
}
|
|
|
|
// ── tab 1 -> tab 2 (shell seam) ─────────────────────────────────────────────
|
|
// country-poster-wall already dispatches this scoped window event when a card's
|
|
// "Show on map" is clicked — country-poster-wall.vue:78 sets event-name. Until now
|
|
// ONLY the app shell listened (app-wrapper-material/index.html), so the action did
|
|
// nothing in a dropped embed or a nested composite: the event fired into a document
|
|
// with no handler. Owning it here is what makes the two tabs one element rather
|
|
// than two pages that happen to share a bundle.
|
|
// Pane elements by id, so the map pane can be addressed after mount.
|
|
const paneEls = {}
|
|
|
|
const SHOW_ON_MAP = 'doxa-country-posters:show-on-map'
|
|
function onShowOnMap(e) {
|
|
const d = e?.detail || {}
|
|
if (d.instanceId && d.instanceId !== instanceId.value) return
|
|
go('map')
|
|
// ★ THE FORWARDING THAT WAS NEVER WRITTEN. This handler used to end at go('map'), so the tab
|
|
// changed and the map was never told anything at all -- measured exactly: "the camera
|
|
// stays at world view, nothing selected". It is not a timing bug and no amount of waiting fixes
|
|
// it; the link simply did not exist.
|
|
//
|
|
// Same shape as upg-100's: RECORD the request as an attribute, then announce it by event.
|
|
// An attribute is state, so it survives custom-element upgrade and is still readable when the
|
|
// map pane finally gets there (and when its style finishes loading later still); the event is
|
|
// only the live path for a map that is already up. Both end in one applySelectCountry() inside
|
|
// the pane, so there is one apply path with two triggers.
|
|
//
|
|
// The attribute carries the country ID, not its geometry: countries-map resolves the id against
|
|
// the same library/data/country-posters.json the wall renders from, so the bbox has exactly one
|
|
// source of truth rather than being copied through an attribute.
|
|
// An Indian state's card (the state table's ids are 'india-<code>') shows India: this map draws countries,
|
|
// not states, so framing the country is the honest answer rather than a world view with nothing selected.
|
|
const listId = d.listId ?? null
|
|
const countryId = typeof listId === 'string' && listId.startsWith('india-') ? 'country-ind' : listId
|
|
const el = paneEls.map
|
|
if (!el) return
|
|
if (countryId) el.setAttribute('select-country', String(countryId))
|
|
else el.removeAttribute('select-country')
|
|
el.dispatchEvent(new CustomEvent('doxa:select-country', { detail: { countryId } }))
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (!stage.value) return
|
|
column = buildPaneColumn(spec.value, { host: stage.value })
|
|
if (!column) return
|
|
for (const pane of column.panes) {
|
|
if (!pane.config) continue
|
|
// ★ KEEP THE ELEMENT. It used to be created and dropped on the same line, so there was no
|
|
// handle to address the map pane with later -- which is part of why "Show on map" could only
|
|
// ever switch tabs here. upg-100-mfe already retained its pane elements; this matches it.
|
|
const el = paneElement(pane)
|
|
paneEls[pane.id] = el
|
|
column.mountEl(pane.id)?.appendChild(el)
|
|
}
|
|
// Tabs are a readout as well as a control — follow the user's scroll.
|
|
markActivePane(column.current() || view.value) // the fresh-load state, before any switch
|
|
offScroll = column.onScroll(() => {
|
|
const cur = column.current()
|
|
if (cur && cur !== view.value) view.value = cur
|
|
if (cur) markActivePane(cur) // always: a switcher-driven scroll already set view.value
|
|
})
|
|
window.addEventListener(SHOW_ON_MAP, onShowOnMap)
|
|
// profile-config `view` opens a chosen pane ("map" or "wall"), so an embed can start on the map.
|
|
const opening = cfg.value?.view
|
|
if (opening && opening !== firstPaneId.value && spec.value.panes.some((p) => p.id === opening)) go(opening)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener(SHOW_ON_MAP, onShowOnMap)
|
|
offScroll?.()
|
|
column?.destroy()
|
|
column = null
|
|
})
|
|
|
|
// Share the WHOLE MFE: this composite profile's own leaf page, TREE-RELATIVE
|
|
// (<bundle>/<profile>/index.html) — ShareButton joins it to the served tree root.
|
|
// ★ Sharing the COMPOSITE, not a child, is the BUG-SHARE-FROM-CAP4-COMPOSITE bug
|
|
// this pattern already paid for once.
|
|
const SHARE_EMBED_PATH = 'country-posters/country-posters-mfe/index.html'
|
|
|
|
// Child components' <style> blocks are dropped by defineCustomElement, so ALL CSS
|
|
// goes through useShadowStyles: the layout's grid styles + this element's chrome.
|
|
useShadowStyles(PANE_GRID_STYLES + `
|
|
/* SELF-SIZE (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT). Every layer inside this element is
|
|
position:absolute / height:100%, so the whole chain resolves to ZERO unless the HOST is
|
|
sized. A standalone leaf page papers over it with a page-level rule; inside a tab layout
|
|
no such rule exists and every pane collapses to 0 — the "elements are not embedding"
|
|
report. Declaring :host makes the element fill its slot in every deploy method. */
|
|
:host{ display:block; width:100%; height:100%; min-height:420px; }
|
|
/* ── palette: DX-life material light. NO AUTO-DARK (decided 2026-09-20): a system set to
|
|
dark mode must not flip this element. ── */
|
|
.cpmfe{
|
|
--ui-bg:#ffffff; --ui-bg-elevated:#f3f3f1; --ui-bg-accented:#f3f3f1;
|
|
--ui-border:#d1d4cd; --ui-text:#3b463d; --ui-text-muted:#6d766e; --ui-primary:#92b195;
|
|
--header:56px;
|
|
position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;
|
|
background:var(--ui-bg); color:var(--ui-text);
|
|
font:14px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;
|
|
}
|
|
.cpmfe .hdr{
|
|
flex:0 0 var(--header); height:var(--header); position:relative;
|
|
display:flex; align-items:center; justify-content:flex-end; padding:0 12px;
|
|
background:var(--ui-bg); border-bottom:1px solid var(--ui-border);
|
|
}
|
|
/* Only the PLACEMENT lives here — the control's metrics are the shared component's. */
|
|
.viewsel{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); }
|
|
.cpmfe .stage{ position:relative; flex:1 1 auto; min-height:0; }
|
|
.pane > .pane-mount > *{ position:absolute; inset:0; width:100%; height:100%; display:block; }
|
|
`, 'country-posters-mfe')
|
|
</script>
|
|
|
|
<template>
|
|
<div class="cpmfe">
|
|
<header class="hdr">
|
|
<ViewSelect
|
|
:views="spec.panes"
|
|
:current="view"
|
|
:legend="t('tabs.selectView')"
|
|
:label="t('tabs.views')"
|
|
@select="go"
|
|
/>
|
|
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" shape="rounded-square" />
|
|
</header>
|
|
<div class="stage" ref="stage"></div>
|
|
</div>
|
|
</template>
|