Replace poster-wall's Download/Prayer-List buttons with three real print actions
Some checks are pending
build-gate / build (push) Waiting to run

Driver, 2026-09-23: the UPG-100 list card should offer three PRINT options, each
opening the browser's own print dialog on a rendered preview (never a silent file
download), in this order: Print a UPG-100 list (the point of the app), Print a UPG
prayer poster, Print a UPG card deck.

- printList100() and printCardDeck() reuse printPoster()'s print-preview window
  mechanism (extracted to openPrintWindow()) rather than inventing a second one.
- Both fetch real per-people-group records via the same normalize.js +
  selector-evaluator.js pipeline the sibling map profile already uses, so the map,
  the list print and the card deck can never disagree about which PGs belong to a
  list. Found and fixed along the way: 12 of 27 lists carry a `pg_slugs` selector
  that selector-evaluator.js intentionally throws on (a documented special case in
  upg-100-list.vue) — my first pass called it unconditionally, which silently ate
  every click on the India/China lists before reaching window.open(). Caught only
  by driving a real click and polling the HUD, not by reading the code.
- Card deck is new code (no card/deck generator exists in this repo or in
  root-dx-life to port — searched first); one card per people group, print-paginated
  with `break-after:page`, styled after the poster-wall's own tape/card vocabulary.
- Removed the now-orphaned exportPoster()/downloadPacket()/download() — the raw
  Download button and the "Prayer List: coming soon" placeholder were exactly the
  two buttons the Driver objected to.

Verified live: instrumented window.open/window.print via Playwright, clicked all
three buttons for real. Each opens exactly one window and calls print() exactly
once (List: 1/1, Card Deck: 1/1; Poster: 1 window opened, print() correctly does
NOT fire when the full-res poster 404s in this default, bundlePosters:false tree —
the documented degraded state, confirmed by screenshot).
This commit is contained in:
doxa-maps-88655c70 2026-09-23 16:20:04 +00:00
parent 873a99d1a6
commit 2772486663
5 changed files with 275 additions and 104 deletions

View file

@ -43,6 +43,13 @@ import { useViewportScrim } from '@map/utils/viewportScrim.js'
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
import { lists as clusterLists, asia_lists as clusterAsiaLists, uncovered_lists as clusterUncoveredLists } from './data/clusters.json'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
// PORTED from the sibling map profile (upg-100-list.vue) rather than re-invented — same
// normalize+evaluate pipeline it already uses to turn the live /people-groups/list API
// payload + a list's `selector` into the exact PG rows that belong to that list. This is
// the per-people-group data the UPG-100 LIST print and the CARD DECK print both need;
// clusters.json itself only carries list-level rows (id/label/count/selector/bbox).
import { normalizeRecords, extractRecords } from './data/normalize.js'
import { evaluate as evaluateSelector } from './data/selector-evaluator.js'
const clusters = { lists: clusterLists, asia_lists: clusterAsiaLists, uncovered_lists: clusterUncoveredLists }
const profileConfig = inject('profileConfig', ref(null))
@ -66,6 +73,14 @@ const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
// shows "Poster not available yet" via the img onerror).
const posterUrl = (id) => `${assetsBase.value}/posters/${id}.webp`
// Config-only, fail-closed API base — SAME resolution as the sibling map profile
// (upg-100-list.vue:297). No default host: a dev harness that never sets
// window.MAP_APP_API_URL simply gets no PG-level data, and the two print paths
// below degrade honestly (see printList100Notice / printCardDeckNotice).
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
@ -250,54 +265,57 @@ const EXPORT_SPEC = { widthIn: 8.5, heightIn: 12.75, dpi: 150 } // single-canv
const exportState = ref(null) // { label, phase, message, pct, kind }
const { scrimRef, scrimStyle } = useViewportScrim(exportState)
async function exportPoster(list) {
if (exportState.value) return // one at a time
// ── SERVE THE PRE-RENDERED POSTER — do NOT re-render ────────────────────────
// This used to call useMapPoster(), which spins up a hidden Mapbox map and
// captures its canvas. That produced a DIFFERENT ARTEFACT from the preview on
// this very card: a bare basemap capture with none of the template — no title,
// no scripture bands, no facts table, no numbered pins, no leader lines.
//
// Brief: "whenever I click print map on mobile I get the exact same preview
// map I'm looking at." Serving the pre-rendered image makes preview and
// download identical BY CONSTRUCTION rather than by keeping two renderers in
// sync — and it works on Android, where the Node generator cannot run at all.
//
// The file is a 1500px WebP (~217 KB, 5in at 300dpi) built by
// library/scripts/posters/make-previews.py from the same 3000px master that
// produced the thumbnail above.
exportState.value = { label: list.label, kind: 'Poster', phase: 'fetch',
message: 'fetching poster', pct: 20 }
try {
const url = posterUrl(list.id)
const res = await fetch(url)
if (!res.ok) throw new Error(`poster not found (${res.status})`)
exportState.value = { ...exportState.value, phase: 'save', message: 'saving', pct: 80 }
const blob = await res.blob()
download(blob, `doxa-upg-100-${list.id}-poster.webp`)
exportState.value = { ...exportState.value, phase: 'done', message: 'saved', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
} catch (e) {
exportState.value = { label: list.label, kind: 'Poster', phase: 'error',
message: String(e?.message || e).slice(0, 140), pct: 0 }
// exportPoster()/downloadPacket()/download() REMOVED (2026-09-23) — the Driver's brief was
// explicit: 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 (Save as PDF in
// the OS print dialog that printList100/printPoster/printCardDeck now all open) covers the
// "get a file" need without a fourth competing button. posterUrl()/exportState/the modal
// all stay — printPoster below still uses posterUrl(), and all three print paths still
// share the exportState HUD for progress/errors.
// ─── SHARED PRINT-WINDOW MECHANISM ────────────────────────────────────────────
// ONE mechanism, reused by all three "Print …" actions (poster / list / card
// deck) rather than three ad-hoc window.opens. Extracted from the poster path,
// which already carries the load-bearing fix documented below.
//
// ★ NO `noopener` HERE, AND THAT IS THE WHOLE FIX (2026-09-23). `noopener` makes window.open()
// return null BY SPECIFICATION — severing the opener relationship is exactly what the flag does,
// so there is no handle to hand back. With it, `w` was always null, the document.write preview
// below was UNREACHABLE FOR EVERY USER, and every click fell through to a raw-content fallback.
// MEASURED in a default (non-headless-flagged) browser: window.open('','_blank','noopener') -> null,
// the identical call without the flag -> a usable handle. I first attributed the null to this
// headless box; it is not environmental, it is what the flag means.
// The window we write into carries same-origin markup we generate ourselves, so the opener
// reference it regains is our own — there is nothing hostile for `noopener` to protect against here.
//
// `bodyHtml` is the FULL <html> document string (caller owns <head>/<style>/<body>). The caller's
// content is responsible for its own print trigger — pass `autoprint:true` to have this helper
// append the `window.focus();window.print()` bootstrap (used by markup with no <img onload> of
// its own to hang the call off).
function openPrintWindow(title, bodyHtml, { autoprint = false } = {}) {
const w = window.open('', '_blank', 'width=980,height=1100')
if (!w) return null // popup blocked — caller decides the fallback
w.document.open()
w.document.write(bodyHtml)
if (autoprint) {
// Appended AFTER the caller's markup so it runs once the document is parsed —
// matches the <img onload> timing the poster path already relies on.
w.document.write(
'<script>window.addEventListener("load",function(){setTimeout(function(){window.focus();window.print()},150)})</' + 'script>')
}
w.document.close()
return w
}
function downloadPacket(list) {
// NOT an error — this is unbuilt, not broken. It used to raise phase:'error',
// which reads to a user as "something went wrong" rather than "not yet".
// Brief: mark it Coming soon; the prayer-list design is still to be specified.
exportState.value = { label: list.label, kind: 'Prayer List', phase: 'soon',
message: 'Coming soon — the prayer list for each people group is in build.', pct: 0 }
}
function download(blob, name) {
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = name
document.body.appendChild(a); a.click(); a.remove()
setTimeout(() => URL.revokeObjectURL(a.href), 4000)
// Escape the handful of characters that break out of an HTML text node or a "..." attribute.
// Print-window markup is built by string concatenation (no template-literal/backtick rule for
// shadow CSS extends to every string-built document in this file for the same reason: one stray
// character silently breaks the whole write()), so every field sourced from data (list labels, PG
// names, country/religion labels) is routed through this before insertion.
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
// PRINT a poster: open it in a print-friendly preview window and raise the browser's
@ -315,26 +333,8 @@ async function printPoster(list) {
// onload -> print() so the dialog opens on the poster the user is looking at. The
// poster is a 3ft square print (see the template) → @page portrait, image scaled to
// fit the sheet with a small margin.
// ★ NO `noopener` HERE, AND THAT IS THE WHOLE FIX (2026-09-23). `noopener` makes window.open()
// return null BY SPECIFICATION — severing the opener relationship is exactly what the flag does,
// so there is no handle to hand back. With it, `w` was always null, the document.write preview
// below was UNREACHABLE FOR EVERY USER, and every click fell through to the raw-image fallback:
// a .webp in a tab, no print dialog. The card claimed "opens in-browser print preview + dialog".
// MEASURED in a default (non-headless-flagged) browser: window.open('','_blank','noopener') -> null,
// the identical call without the flag -> a usable handle. I first attributed the null to this
// headless box; it is not environmental, it is what the flag means.
// `noopener` STAYS on the fallback below, which only navigates to a URL and never needs a handle,
// so the protection is kept exactly where it costs nothing. The window we write into carries
// same-origin markup we generate ourselves, so the opener reference it regains is our own.
const w = window.open('', '_blank', 'width=900,height=1000')
if (!w) { // popup blocked → open the image tab
window.open(url, '_blank', 'noopener')
exportState.value = { ...exportState.value, phase: 'done', message: 'opened poster', pct: 100 }
setTimeout(() => { exportState.value = null }, 900); return
}
const title = `Doxa UPG-100 poster — ${String(list.label || list.id).replace(/[<>&]/g, '')}`
w.document.open()
w.document.write(
const title = `Doxa UPG-100 poster — ${escapeHtml(list.label || list.id)}`
const w = openPrintWindow(title,
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
'<style>@page{size:auto;margin:12mm}html,body{margin:0;height:100%;background:#fff}' +
'body{display:flex;align-items:center;justify-content:center}' +
@ -344,7 +344,11 @@ async function printPoster(list) {
' onload="setTimeout(function(){window.focus();window.print()},150)"' +
' onerror="document.body.innerHTML=\'<p style=&quot;font:16px system-ui;padding:24px&quot;>Poster not available yet.</p>\'">' +
'</body></html>')
w.document.close()
if (!w) { // popup blocked → open the image tab
window.open(url, '_blank', 'noopener')
exportState.value = { ...exportState.value, phase: 'done', message: 'opened poster', pct: 100 }
setTimeout(() => { exportState.value = null }, 900); return
}
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
} catch (e) {
@ -353,6 +357,167 @@ async function printPoster(list) {
}
}
// ─── PER-LIST PEOPLE-GROUP RECORDS ─────────────────────────────────────────────
// Both new print actions (the UPG-100 LIST and the CARD DECK) need the actual
// people-group rows that belong to ONE list, not just its {id,label,count}
// summary. This fetches the live API once per click, normalizes it with the
// SAME normalize.js the sibling map profile uses (flattens the {value,label}
// fields), then narrows to this list's members via the SAME selector-evaluator
// the map uses to paint the tapestry — so "what's in this list" can never
// disagree between the map, the list print and the card deck.
async function fetchListRecords(list) {
if (!API_BASE) throw new Error('no API configured')
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,religion'
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`)
const records = normalizeRecords(extractRecords(await resp.json()))
// ★ pg_slugs BYPASSES the shared evaluator — MEASURED live (2026-09-23): 12 of the 27
// lists (the entire India + China buckets) carry `{ type:'pg_slugs', values:[...] }`,
// and selector-evaluator.js documents that it "intentionally THROWS" on that type
// (upg-100-list.vue:1455-1461, "BUG-A FIX"). Calling evaluateSelector() unconditionally
// threw on every India/China card, which silently ate the click (the async function's
// catch surfaced the evaluator's own error text, and window.open() was never reached —
// found by polling the exportState HUD after a real click during verification, not by
// inspection: the button looked fine and clicked fine). Ported the SAME special-case
// upg-100-list.vue already uses: pg_slugs matches slug ∈ selector.values directly;
// every other selector type still goes through the shared evaluator so the map, the
// list print and the card deck can never disagree about membership.
const slugs = (list.selector && list.selector.type === 'pg_slugs')
? new Set(list.selector.values || [])
: evaluateSelector(list.selector, records)
return records.filter((r) => r && slugs.has(r.slug))
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')))
}
// PRINT #1 (Driver's FIRST option, "that's what the whole app was about"): the UPG-100
// LIST itself — the prayer list of up to 100 people groups this card represents, as a
// print-paginated table (name / country / population / religion), opened straight into
// the browser's print dialog. Same mechanism as printPoster(): a dedicated window,
// document.write, print() on load — never a silent file download.
async function printList100(list) {
if (exportState.value) return
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'fetch',
message: 'fetching people-group records', pct: 30 }
try {
const rows = await fetchListRecords(list)
const title = `Doxa UPG-100 list — ${escapeHtml(list.label || list.id)}`
const rowsHtml = rows.map((r) => (
'<tr><td class="c-name">' + escapeHtml(r.name) + '</td>' +
'<td>' + escapeHtml(r.country_code_label || r.country_code || '—') + '</td>' +
'<td class="c-num">' + escapeHtml(r.population ? Number(r.population).toLocaleString() : '—') + '</td>' +
'<td>' + escapeHtml(r.religion_label || '—') + '</td>' +
'<td class="c-pray">□</td></tr>'
)).join('')
const w = openPrintWindow(title,
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
'<style>' +
'@page{size:letter portrait;margin:14mm 12mm}' +
'html,body{margin:0;background:#fff;color:#0f1419;' +
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}' +
'.hd{margin-bottom:14px}' +
'.hd h1{margin:0 0 3px;font-size:18px}' +
'.hd p{margin:0;font-size:11px;color:#5b6672}' +
'table{width:100%;border-collapse:collapse;font-size:11px}' +
'thead th{text-align:left;border-bottom:2px solid #0f1419;padding:5px 6px;' +
'font-size:9.5px;text-transform:uppercase;letter-spacing:.05em}' +
'tbody td{padding:5px 6px;border-bottom:1px solid #e3e6ea}' +
'.c-name{font-weight:600}.c-num{font-variant-numeric:tabular-nums}' +
'.c-pray{width:26px;text-align:center;font-size:13px}' +
'tbody tr{break-inside:avoid}' +
'@media print{.hd{margin-top:0}}' +
'</style></head><body>' +
'<div class="hd"><h1>' + title + '</h1>' +
'<p>' + rows.length + ' people groups — pray for each one by name (Luke 9:14). ' +
'Check the □ as you pray.</p></div>' +
'<table><thead><tr><th>People group</th><th>Country</th><th>Population</th>' +
'<th>Religion</th><th></th></tr></thead><tbody>' + rowsHtml + '</tbody></table>' +
'</body></html>', { autoprint: true })
if (!w) {
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
message: 'Pop-up blocked — allow pop-ups for this site and try again.', pct: 0 }
return
}
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
} catch (e) {
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
message: String(e?.message || e).slice(0, 140) + (API_BASE ? '' :
' (no API configured in this preview — set window.MAP_APP_API_URL)'), pct: 0 }
}
}
// PRINT #3: the CARD DECK — one prayer card PER PEOPLE GROUP in this list, laid out for
// a real printer with `break-after:page` between cards so each card lands on its own
// sheet (or, cut down, its own card). New code — no card/deck generator exists anywhere
// in root-dx-life or in this repo's library/ to port (searched; only a per-COUNTRY
// pre-baked PDF pointer exists there, `prayer-card-{country}-{lang}.pdf`, not a per-PG
// generator). Styling below borrows the poster-wall's own vocabulary (tape header, thin
// card border, Material Light palette) instead of inventing a fourth visual language.
async function printCardDeck(list) {
if (exportState.value) return
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'fetch',
message: 'fetching people-group records', pct: 30 }
try {
const rows = await fetchListRecords(list)
const title = `Doxa UPG prayer cards — ${escapeHtml(list.label || list.id)}`
const cardsHtml = rows.map((r) => (
'<section class="card">' +
'<div class="tape">' + escapeHtml(list.label || list.id) + '</div>' +
'<h2>' + escapeHtml(r.name) + '</h2>' +
'<dl>' +
'<dt>Country</dt><dd>' + escapeHtml(r.country_code_label || r.country_code || '—') + '</dd>' +
'<dt>Population</dt><dd>' + escapeHtml(r.population ? Number(r.population).toLocaleString() : '—') + '</dd>' +
'<dt>Affinity bloc</dt><dd>' + escapeHtml((r.rop1 && r.rop1.label) || '—') + '</dd>' +
'<dt>Religion</dt><dd>' + escapeHtml(r.religion_label || '—') + '</dd>' +
'</dl>' +
'<p class="verse">"Ask the Lord of the harvest to send out workers into his harvest field." — Matthew 9:38</p>' +
'</section>'
)).join('')
const w = openPrintWindow(title,
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
'<style>' +
'@page{size:letter portrait;margin:10mm}' +
'html,body{margin:0;background:#f3f3f1;color:#3b463d;' +
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}' +
// Screen preview: a responsive grid so the deck can be reviewed before printing.
'.deck{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:18px;padding:18px}' +
'.card{position:relative;background:#fff;border:1px solid #d1d4cd;border-radius:6px;' +
'padding:22px 18px 16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}' +
'.tape{position:absolute;top:-9px;left:50%;transform:translateX(-50%);' +
'background:#92b195;color:#1c2a1f;font-size:9px;font-weight:700;text-transform:uppercase;' +
'letter-spacing:.06em;padding:3px 10px;border-radius:999px;white-space:nowrap}' +
'.card h2{margin:6px 0 10px;font-size:16px;font-weight:700}' +
'.card dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11.5px}' +
'.card dt{color:#6d766e;font-weight:600}' +
'.card dd{margin:0}' +
'.card .verse{margin:12px 0 0;padding-top:10px;border-top:1px solid #d1d4cd;' +
'font-size:10.5px;font-style:italic;color:#6d766e;line-height:1.4}' +
// PRINT PAGINATION: one card per sheet. `break-after:page` on every card except
// the last is what makes a "deck" out of a document instead of a wall of cards
// shrunk to fit — the Driver's ask was "a real printer produces a usable deck".
'@media print{' +
'.deck{display:block;padding:0}' +
'.card{box-shadow:none;border-radius:0;border:2px solid #3b463d;' +
'break-after:page;page-break-after:always;height:100%;box-sizing:border-box}' +
'.card:last-child{break-after:auto;page-break-after:auto}' +
'}' +
'</style></head><body>' +
'<div class="deck">' + cardsHtml + '</div>' +
'</body></html>', { autoprint: true })
if (!w) {
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'error',
message: 'Pop-up blocked — allow pop-ups for this site and try again.', pct: 0 }
return
}
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
} catch (e) {
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'error',
message: String(e?.message || e).slice(0, 140) + (API_BASE ? '' :
' (no API configured in this preview — set window.MAP_APP_API_URL)'), pct: 0 }
}
}
function dismissExport() { exportState.value = null }
useShadowStyles(
@ -570,13 +735,19 @@ useShadowStyles(
a step for nothing. -->
<fieldset class="tape pfs">
<legend>Print Options</legend>
<!-- PRINT = open the in-browser preview + print dialog (primary). DOWNLOAD =
save the file (secondary). Prayer List = coming soon. -->
<!-- Driver, 2026-09-23: "It should say PRINT... open up a print dialog inside of
your browser, not bring you to a new page... render or generate that poster
and then let you print it out to PDF or print it directly to a printer." All
THREE open the SAME in-browser print-preview + dialog mechanism
(openPrintWindow, shared by printList100/printPoster/printCardDeck) — no
silent file download as a primary action. LIST leads because it is what the
whole app is about (Driver's ordering). -->
<button type="button" class="pbtn pbtn-print" @click="printList100(list)"
:aria-label="`Print the ${list.label} UPG-100 list`">🖨 Print a UPG-100 list</button>
<button type="button" class="pbtn pbtn-print" @click="printPoster(list)"
:aria-label="`Preview and print the ${list.label} poster`">🖨 Print poster</button>
<button type="button" class="pbtn" @click="exportPoster(list)"
:aria-label="`Download the ${list.label} poster file`">Download</button>
<button type="button" class="pbtn" @click="downloadPacket(list)">Prayer List</button>
:aria-label="`Print the ${list.label} UPG prayer poster`">🖨 Print a UPG prayer poster</button>
<button type="button" class="pbtn pbtn-print" @click="printCardDeck(list)"
:aria-label="`Print the ${list.label} UPG card deck`">🖨 Print a UPG card deck</button>
</fieldset>
</div>
</div>

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 <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-5P8QseUJZQ3XBbreJUDZ7xqi1Pvzd2c/Ns2GuXhK+6tjZ2tVSUM1gRiC6vdg6bSJ" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-4Msd7/FhwYfrRjJXy7bgnbvQcLaQ3p6kjFZAm9VgnoTeZxOu25E4v0IAvw1udho0" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-5P8QseUJZQ3XBbreJUDZ7xqi1Pvzd2c/Ns2GuXhK+6tjZ2tVSUM1gRiC6vdg6bSJ" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-4Msd7/FhwYfrRjJXy7bgnbvQcLaQ3p6kjFZAm9VgnoTeZxOu25E4v0IAvw1udho0" 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 <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-5P8QseUJZQ3XBbreJUDZ7xqi1Pvzd2c/Ns2GuXhK+6tjZ2tVSUM1gRiC6vdg6bSJ" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-4Msd7/FhwYfrRjJXy7bgnbvQcLaQ3p6kjFZAm9VgnoTeZxOu25E4v0IAvw1udho0" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: