PosterWall can print the poster instead of downloading it — the Driver-rejected UX it still encoded

★ THE BLOCKER ON MIGRATING THE 100-LIST WAS NEVER ONLY A MISSING CAPABILITY. It was
a Driver-rejected UX baked into the shared component, and migrating as planned would
have shipped it.

my-upg-100-list/poster-wall.vue:280 records the ruling verbatim (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." exportPoster(),
downloadPacket() and download() were REMOVED from that wall for that reason.

PosterWall still had both: exportPoster() downloads a .webp unconditionally, at two
call sites, and downloadPacket() renders the "coming soon" placeholder whenever
there is no records provider. Consuming it would have reintroduced the exact two
buttons the Driver named.

Two props close that, both defaulting to today's behaviour so country-posters and
key-cities are untouched:

  posterAction: 'download' | 'print' — 'print' opens the OS print dialog on the
  poster. BOTH delivery paths (the shipped .webp and the browser-rasterised
  fallback) now route through ONE deliverPoster(), so the modes cannot drift or be
  applied to only one of them. Printed blob urls are held until unmount rather than
  revoked at print time: the preview <img> is still reading from them, and revoking
  early prints a blank sheet.

  hidePrayerListWhenSoon — suppress the Prayer List button rather than show it as a
  placeholder. The placeholder stays honest where the feature is genuinely unbuilt;
  it is only wrong on a wall that DOES print the list.

VERIFIED AT THE RENDERED LAYER, both modes, by counting the two outcomes that
actually distinguish them:
  poster-action="print"  -> 1 print frame opened, 0 downloads
  default (prop absent)  -> 0 print frames,       1 download
Counted at appendChild, where openPrintFrame attaches the iframe, rather than by
polling for iframes — the frame SELF-REMOVES on afterprint, so polling finds none
and reads as a failure.

⚠ Two probe mistakes worth recording, because both looked like product bugs: a HUD
selector that matched <script>/<style> text and returned the page's whole token
ladder, and a first click that landed on INDIA — which country-posters marks
`unsupported` (855 groups do not fit a sheet), so it short-circuited with zero
poster requests. Skipping cards showing .ph.is-soon fixed it; the next click fetched
country-chn.webp and completed.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 09:37:35 +00:00
parent f824329466
commit 9f076ffbc8
13 changed files with 312 additions and 251 deletions

View file

@ -39,7 +39,7 @@
* CROSS-PANE COMMS — this wall NEVER imports a map. It dispatches a window
* CustomEvent scoped by instanceId, so several embeds on one page never cross-talk.
*/
import { computed, ref } from 'vue'
import { computed, ref, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { usePosterPrints } from '@map/composables/usePosterPrints.js'
import { useI18n } from 'vue-i18n'
@ -102,6 +102,25 @@ const props = defineProps({
// comment saying they "come from the renderer's own STOCK table so this list cannot
// drift" — which was the intent, not the implementation. Deriving them makes it true.
cardStocks: { type: [Boolean, Array], default: false },
// ★ DOWNLOAD OR PRINT? A DRIVER RULING, NOT A PREFERENCE — and the reason the
// 100-list could not simply adopt this component.
// Default 'download' is what country-posters and key-cities do today and keeps
// them byte-for-byte unchanged. 'print' opens the OS print dialog on the poster
// instead, which is what my-upg-100-list/poster-wall.vue:280 records as the
// Driver's explicit brief (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." Printing to PDF from that dialog covers the
// get-a-file need without a fourth competing button.
// Migrating the 100-list onto this component WITHOUT this prop would have
// reintroduced both rejected buttons — the capability gap was never the whole
// blocker; the shared component still encoded the pre-brief design.
posterAction: { type: String, default: 'download' }, // 'download' | 'print'
// Hide the Prayer List button entirely, rather than showing it as a
// "coming soon" placeholder. The placeholder is honest where the feature is
// genuinely unbuilt, and it is the OTHER button the Driver objected to on a wall
// where the list DOES print — so a wall with a records provider can suppress it.
hidePrayerListWhenSoon: { type: Boolean, default: false },
// EMBEDDED mode (added 2026-09-24 for the regions-tab poster dropdown). The wall's root is
// `position:absolute;inset:0` because in country-posters and my-upg-100-list it IS a pane and
// fills it. Dropped inside another component — a region card, say — that absolute root covers
@ -217,16 +236,10 @@ async function exportPoster(item) {
// an isolated document, so a missing stylesheet makes every fill fall back to
// black with no error at all.
if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank')
exportState.value = { ...exportState.value, phase: 'save', message: 'saving', pct: 80 }
download(blob, `${props.downloadPrefix}-${item.id}-poster.webp`)
exportState.value = { ...exportState.value, phase: 'done', message: 'saved', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
await deliverPoster(blob, item)
return
}
exportState.value = { ...exportState.value, phase: 'save', message: 'saving', pct: 80 }
download(await res.blob(), `${props.downloadPrefix}-${item.id}-poster.webp`)
exportState.value = { ...exportState.value, phase: 'done', message: 'saved', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
await deliverPoster(await res.blob(), item)
} catch (e) {
exportState.value = { label: item.label, kind: 'Poster', phase: 'error',
message: String(e?.message || e).slice(0, 140), pct: 0 }
@ -300,6 +313,53 @@ function downloadPacket(item) {
prints.printList(item)
}
// ONE delivery point for the finished poster, so 'download' and 'print' cannot
// drift apart or be applied to only one of the two paths (shipped .webp vs
// browser-rasterised). Both callers in exportPoster() route through here.
async function deliverPoster(blob, item) {
if (props.posterAction === 'print') {
exportState.value = { ...exportState.value, phase: 'save', message: 'opening the print dialog', pct: 80 }
const { openPrintFrame } = await import('@map/composables/printInPlace.js')
const url = URL.createObjectURL(blob)
posterObjectUrls.push(url)
// A 3ft SQUARE on a PORTRAIT sheet must fit by its SHORTER axis: scaling to the
// full page WIDTH makes it taller than the printable height and it overflows onto
// a second page. Constrain both axes and let object-fit letterbox it.
openPrintFrame(
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>'
+ esc(`${props.downloadPrefix} — ${item.label || item.id}`) + '</title>'
+ '<style>@page{size:letter portrait;margin:12mm}'
+ 'html,body{margin:0;height:100%;background:#fff}'
+ 'body{display:flex;align-items:center;justify-content:center}'
+ 'img{max-width:100%;max-height:100vh;object-fit:contain;display:block}'
+ '@media print{body{height:auto}'
+ 'img{width:auto;max-width:100%;max-height:100%;object-fit:contain}}</style></head>'
+ `<body><img src="${url}" alt="${esc(item.label || item.id)}"></body></html>`,
{ autoprint: true },
)
} else {
exportState.value = { ...exportState.value, phase: 'save', message: 'saving', pct: 80 }
download(blob, `${props.downloadPrefix}-${item.id}-poster.webp`)
}
exportState.value = { ...exportState.value, phase: 'done',
message: props.posterAction === 'print' ? 'print dialog opened' : 'saved', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
}
// Blob urls for printed posters. NOT revoked when the frame opens — the preview
// <img> is still loading from them and revoking early prints a blank sheet — so
// they are held until this component goes away.
const posterObjectUrls = []
onBeforeUnmount(() => {
for (const u of posterObjectUrls.splice(0)) { try { URL.revokeObjectURL(u) } catch { /* already gone */ } }
})
// Escape the characters that break out of an HTML text node or a "..." attribute.
// The print document is built by string concatenation, so every interpolated value
// — a list label straight from data — goes through this first.
const esc = (v) => String(v == null ? '' : v)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
function download(blob, name) {
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
@ -514,7 +574,8 @@ useShadowStyles(
<fieldset class="tape pfs">
<legend>{{ t('buttons.printOptions') }}</legend>
<button type="button" class="pbtn" @click="downloadPacket(item)">{{ t('buttons.prayerList') }}</button>
<button v-if="!(hidePrayerListWhenSoon && !canPrintRows)" type="button" class="pbtn"
@click="downloadPacket(item)">{{ t('buttons.prayerList') }}</button>
<!-- CARD DECK — only when this wall can supply rows. A button that
cannot work is worse than an absent one, so it is gated on the
records provider rather than shown and then failing at click. -->

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 <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-+pUiLAGZgzfsJf4mn6c5KpM7Tf/SyfruNH1jAPyzOv8O8fN5lJ81oCADffybwdVw" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-+pUiLAGZgzfsJf4mn6c5KpM7Tf/SyfruNH1jAPyzOv8O8fN5lJ81oCADffybwdVw" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ" 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 <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-+pUiLAGZgzfsJf4mn6c5KpM7Tf/SyfruNH1jAPyzOv8O8fN5lJ81oCADffybwdVw" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -154,7 +154,7 @@
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
"output": "doxa-maps/country-posters/country-posters.js",
"integrity": "sha384-+pUiLAGZgzfsJf4mn6c5KpM7Tf/SyfruNH1jAPyzOv8O8fN5lJ81oCADffybwdVw",
"integrity": "sha384-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ",
"elements": [
"country-posters-map"
],
@ -174,7 +174,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/countries-map/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=\"./country-posters/country-posters.js\" integrity=\"sha384-+pUiLAGZgzfsJf4mn6c5KpM7Tf/SyfruNH1jAPyzOv8O8fN5lJ81oCADffybwdVw\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -188,7 +188,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-poster-wall/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=\"./country-posters/country-posters.js\" integrity=\"sha384-+pUiLAGZgzfsJf4mn6c5KpM7Tf/SyfruNH1jAPyzOv8O8fN5lJ81oCADffybwdVw\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -202,7 +202,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-posters-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=\"./country-posters/country-posters.js\" integrity=\"sha384-+pUiLAGZgzfsJf4mn6c5KpM7Tf/SyfruNH1jAPyzOv8O8fN5lJ81oCADffybwdVw\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
}
]
@ -856,7 +856,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-O+KKVXrz1cHdDNNgxmA5OhFk03sIKRUn6nsuvuq0smZaiQTYwqSFTcsfvIJj55TW",
"integrity": "sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z",
"elements": [
"regions-dashboard"
],
@ -876,7 +876,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-O+KKVXrz1cHdDNNgxmA5OhFk03sIKRUn6nsuvuq0smZaiQTYwqSFTcsfvIJj55TW\" 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-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z\" 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>"
}
},
{
@ -890,7 +890,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-O+KKVXrz1cHdDNNgxmA5OhFk03sIKRUn6nsuvuq0smZaiQTYwqSFTcsfvIJj55TW\" 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-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z\" 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>"
}
},
{
@ -904,7 +904,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-O+KKVXrz1cHdDNNgxmA5OhFk03sIKRUn6nsuvuq0smZaiQTYwqSFTcsfvIJj55TW\" 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-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z\" 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-O+KKVXrz1cHdDNNgxmA5OhFk03sIKRUn6nsuvuq0smZaiQTYwqSFTcsfvIJj55TW" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z" 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-O+KKVXrz1cHdDNNgxmA5OhFk03sIKRUn6nsuvuq0smZaiQTYwqSFTcsfvIJj55TW" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z" 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-O+KKVXrz1cHdDNNgxmA5OhFk03sIKRUn6nsuvuq0smZaiQTYwqSFTcsfvIJj55TW" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: