diff --git a/plugin-designs/my-upg-100-list/upg-100-list.vue b/plugin-designs/my-upg-100-list/upg-100-list.vue
index d0fec33ab..613b75b93 100644
--- a/plugin-designs/my-upg-100-list/upg-100-list.vue
+++ b/plugin-designs/my-upg-100-list/upg-100-list.vue
@@ -77,7 +77,7 @@ import ThemeToggleButton from '@map/components/map-controls/ThemeToggleButton.vu
// edits src/ (zero blast radius — md5 unchanged). (research-map.vue:141,1815.)
import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
-import { ensureEngineScript, engineGlobal, engineStylesheetUrl } from '@map/composables/useMapInstance.js'
+import { ensureEngineScript, engineGlobal, ensureEngineCSS, engineStylesheetUrl } from '@map/composables/useMapInstance.js'
// Shapes Arabic / Persian / Hebrew BASEMAP labels. A raw map inherits nothing from the bundle
// entry, so this profile has to install it itself — see the call site below for why the entry's
// registration only ever worked by accident.
@@ -1567,6 +1567,22 @@ function selectedLists() {
// HERE — once, against one fetch of the people-groups list — and handed on as the type every
// consumer already handles. Resolving per zone would refetch the whole catalogue per zone and
// still leave the caller to merge.
+// The packet's own string block for the reader's locale, per-KEY fallback to English exactly as
+// packet.mjs and packet-render do it — a half-translated block must not blank a key that en has.
+let _packetStrings = null;
+async function packetStrings() {
+ if (!_packetStrings) {
+ const all = await import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m);
+ _packetStrings = { ...(all.en || {}), ...(all[packetLang()] || {}) };
+ }
+ return _packetStrings;
+}
+/** `{a}` / `{b}` / `{n}` interpolation; null when the pattern is missing so the caller can default. */
+function fillJoin(pattern, params) {
+ if (!pattern) return null;
+ return String(pattern).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m));
+}
+
async function selectionAsList() {
const lists = selectedLists();
if (!lists.length) throw new Error('nothing selected');
@@ -1587,10 +1603,20 @@ async function selectionAsList() {
}
// The label is what the reader sees on the cover and in the print dialog's title. Name the
// territories they actually chose rather than inventing a title for their selection.
+ // ⚠ THE JOINERS WERE HARDCODED ENGLISH on a cover an Arabic reader sees: ' + ' and ' +N more', printed
+ // straight onto the packet's first page and into the print dialog's title. Found while checking whether
+ // batch selection was already done (dx-shepherd END-DXS-U100B) — the SELECTION path was, but its label
+ // was not localized, so a reader choosing three territories in Arabic got their names in Arabic joined
+ // by an English tail.
+ // zone_join / zone_join_more already exist in all eleven packet-strings blocks (landed with the zone
+ // title generator), translated once for exactly this shape, so this is a reuse rather than new strings.
const names = lists.map((l) => l.label || l.id);
+ const S = await packetStrings();
+ const join2 = (a, b) => fillJoin(S.zone_join, { a, b }) ?? `${a} + ${b}`;
const label = names.length <= 3
- ? names.join(' + ')
- : `${names.slice(0, 2).join(' + ')} +${names.length - 2} more`;
+ ? names.reduce(join2)
+ : fillJoin(S.zone_join_more, { a: names.slice(0, 2).reduce(join2), n: names.length - 2 })
+ ?? `${names.slice(0, 2).join(' + ')} +${names.length - 2} more`;
return { id: 'selection', label, count: slugs.size,
selector: { type: 'pg_slugs', values: [...slugs] } };
}
@@ -2042,6 +2068,29 @@ onMounted(async () => {
// mapbox-under-maplibre reference in this profile, carried as a named exemption in the
// maplibre-embed gate; guarding it lets that exemption be deleted and enforced instead.
// MapLibre ships its own ctrl CSS via ensureEngineScript, so nothing is lost.
+ // ★ THE LIBRARY'S ensureEngineCSS, NOT A HAND-ROLLED COPY (rega END-REGA-SRV / END-DXS-U100C,
+ // 2026-09-27). Mapbox logged "missing CSS declarations" on the BUILT leaf and in the gallery, and the
+ // cause was this block, not a missing :
+ // (a) it only ran when the map element was inside a ShadowRoot — `root instanceof ShadowRoot` — so on
+ // a standalone leaf, where the element is in LIGHT DOM, no stylesheet was ensured at all; and
+ // (b) even in the shadow case it FIRED AND FORGOT, so `new gl.Map` could run before the sheet parsed.
+ // Mapbox checks its declarations AT CONSTRUCTION, which is exactly when the warning appears.
+ // ensureEngineCSS handles both DOM cases (light DOM returns early because the wrapper covers it)
+ // and AWAITS the load/error event with a 2s cap, so construction happens after the sheet is live.
+ // ⚠ AND IT TAKES THE PROVIDER'S OWN URL rather than 'mapbox' hardcoded — the old line asked the registry
+ // for mapbox's stylesheet even under MapLibre, guarded by !isMaplibre. Passing upgProvider means the
+ // guard is unnecessary: a maplibre page gets maplibre's sheet (or none) and never touches
+ // api.mapbox.com, which is the keyless contract the maplibre-embed gate enforces.
+ // MEASURED as the difference from cities-internet, which does not warn: connectivity-map.vue:398-399
+ // calls exactly this pair and this profile called neither.
+ try {
+ const css = engineStylesheetUrl(upgProvider)
+ if (css) await ensureEngineCSS(mapEl.value, css)
+ } catch (_) { /* a stylesheet must never stop the map painting */ }
+ // The legacy shadow-root injection stays ONLY as the belt to that braces: ensureEngineCSS returns early
+ // for light DOM on the assumption a wrapper exists, and a partner embedding the custom element
+ // directly may have no wrapper at all. Harmless when ensureEngineCSS already added the same href — both
+ // paths check for it by href before inserting.
try {
const shadow = !isMaplibre && mapEl.value && mapEl.value.getRootNode()
if (shadow instanceof ShadowRoot) {