my-upg-100-list renders from the shared PosterWall — 890 lines to 174
Some checks are pending
build-gate / build (push) Waiting to run

The original wall is gone: 716 lines of duplicated markup, CSS, export modal and
print wiring deleted. This profile now supplies only what cannot be shared —
REGION_OF/regionFor (its taxonomy, keyed by list id because a selector can be a
wagf_block, a country_codes array or pg_slugs and none of those implies a
continent) and fetchListRecords (the people-groups API, the selector evaluator and
the pg_slugs special case). It joins country-posters (94 lines) and key-cities (75)
on one component, which is what the Driver asked for: "don't recreate the wheel but
imitate upg 100 list".

It took three capability additions across three ticks, because migrating without
them would have REGRESSED this wall rather than consolidated it: cardStocks, then
posterAction="print" and hidePrayerListWhenSoon — the last two a Driver ruling, not
a preference.

★ TWO REAL BUGS, BOTH INVISIBLE AT THE SOURCE LAYER, BOTH BUILDING CLEANLY.

1. groupBy SIGNATURE MISMATCH. regionFor takes an ID; the shared wall's groupBy
takes the whole ITEM. `:group-by="regionFor"` compiled, the page loaded, and then
threw `c.startsWith is not a function` at first render — the whole wall blank, zero
cards, nothing wrong in the source. Adapted in the template rather than changing
regionFor, since the id is what the taxonomy is keyed by.

2. A THROWN FETCH IS A MISS, NOT AN ERROR — and this one is a robustness gap in the
SHARED component, affecting every consumer. fetch REJECTS rather than returning
!ok when a request is blocked or offline, and exportPoster let that reject escape to
the outer catch, skipping the rasterise fallback and showing "Failed to fetch" on a
poster the browser could have generated from posters/<id>.html. The wall this
component was EXTRACTED FROM already guarded it; the extraction did not carry the
guard across. check:poster-print-fallback went PASS -> FAIL with 0 .html requests,
and is green again.

VERIFIED AT THE RENDERED LAYER, all three print actions on the migrated wall:
27 cards, 27 stock pickers, 4 buttons per card — the original's three prints plus
show-on-map. CARD DECK 116 cards / 116 QR codes / @page 2.5in x 3.5in / 0 errors,
identical to pre-migration. POSTER PRINT with every .webp aborted: 1 .html fetched,
"generating the poster", clean finish. PRAYER LIST: 1 people-groups fetch, 0 errors
— positive evidence the provider ran, since that print cannot work without it.
REGRESSION CHECK: country-posters still downloads (0 print frames, 1 download), so
the default is unchanged for the walls already on this component.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 09:54:40 +00:00
parent 626603ca3f
commit 649b0872eb
18 changed files with 256 additions and 956 deletions

View file

@ -217,8 +217,19 @@ async function exportPoster(item) {
exportState.value = { label: item.label, kind: 'Poster', phase: 'fetch',
message: 'fetching poster', pct: 20 }
try {
let res = await fetch(`${base.value}/posters/${item.id}.webp`)
if (!res.ok) {
// ⚠ A THROWN FETCH IS A MISS, NOT AN ERROR. `fetch` rejects — it does not return
// !ok — when the request is blocked, offline, or refused by the network layer.
// Letting that reject escape to the outer catch skips the rasterise fallback
// entirely and reports "Failed to fetch" to the reader, on a poster the browser
// could have generated from posters/<id>.html. Measured 2026-09-24 while
// migrating my-upg-100-list onto this component: check:poster-print-fallback,
// which aborts every .webp at the network layer to force the default-drop
// condition, went from PASS on the old wall to FAIL here with 0 .html requests.
// The wall this component was extracted from already guarded it; the extraction
// did not carry the guard across.
let res = null
try { res = await fetch(`${base.value}/posters/${item.id}.webp`) } catch { /* treated as a miss */ }
if (!res || !res.ok) {
// ★ NO PRE-RENDERED IMAGE? GENERATE IT. The 378 .webp posters were 41 MB in
// every clone, shipped to every reader whether or not they opened one — and a
// publish was already rejected once at a 74 MB asset tree. The poster HTML is

View file

@ -2,90 +2,62 @@
/**
* poster-wall.vue — the UPG-100 POSTER WALL (pane 2 of the my-upg-100-list family).
*
* @description A wall of printable poster cards, one per 100-list entry, each with a "Show on map" action that
* hands off to the map profile in the same bundle.
* @description A wall of printable poster cards, one per 100-list entry, each with a "Show on map"
* action that hands off to the map profile in the same bundle.
*
* A grid of poster cards, one per 100-list, styled as posters taped to a wall:
* HEADER (tape) — a rounded "Show on map" button, NARROWER than the card and
* overlapping the top edge like a strip of tape. Click emits UP
* to the shell, which relays it to the MAP pane.
* MAIN — the poster preview image in a thin-bordered card.
* FOOTER (tape) — a boxy strip, also narrower + overlapping the bottom edge:
* Export poster · Prayer packet.
* ★ MIGRATED ONTO THE SHARED WALL 2026-09-24. This file was 890 lines carrying its own copy of
* markup, CSS, the export modal and three print paths; the rendering now comes from
* @map/components/poster/PosterWall.vue, which country-posters (94 lines) and key-cities (75)
* already consume. This profile supplies ONLY what cannot be shared: its data, its taxonomy and
* its records provider.
*
* This is a SIBLING PROFILE in the same bundle (index.js does
* import.meta.glob('./*.vue'), so dropping this file in registers it). It shares
* the map's data/clusters.json — no second fetch, no duplicated source of truth.
* WHAT COULD NOT BE SHARED, and why each is here rather than in the component:
* REGION_OF / regionFor — the 100-list's own continental taxonomy, keyed by list id because a
* selector can be a wagf_block, a country_codes array or an explicit
* pg_slugs list and none of those implies a continent.
* fetchListRecords — knows the people-groups API, the selector evaluator AND the pg_slugs
* special case. Key Cities and Country Posters have entirely different
* data shapes, which is exactly why the shared wall takes a PROVIDER.
*
* CROSS-PANE COMMS — the decoupling that matters: this wall NEVER imports the map.
* It dispatches a window CustomEvent scoped by instanceId, so several embeds on one
* page never cross-talk. The shell listens and drives the map pane.
* THREE CAPABILITIES HAD TO BE ADDED TO THE SHARED WALL FIRST, over two ticks, because migrating
* without them would have REGRESSED this wall rather than consolidated it:
* cardStocks — the reader picks the prayer-card size at print time.
* posterAction="print" — open the print dialog instead of saving a file.
* hidePrayerListWhenSoon — no "coming soon" placeholder on a wall whose list DOES print.
* The last two are a Driver ruling, not a preference (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."
*
* ASSETS — TWO SIZES, ONE OF WHICH SHIPS (corrected 2026-09-22; this header used to say
* "POSTERS DO NOT EXIST YET. Nothing has been generated", which is no longer true and would
* send a reader off to build a generator that already ran):
* previews/<id>.webp — ~65KB each, 378 generated, SHIPPED in the default tree and served
* live. These are what the wall paints, fetched lazily per card.
* posters/<id>.webp — full print resolution, 378 generated, NOT shipped by default
* (~42MB). See IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF:
* bundling them pushed the tree to 80MB, git-packed to 57MiB, and the
* catalog REJECTED the push — so the all-in version shipped nothing.
* Export and print-preview no longer 404 on a default drop: BOTH now
* fall back to fetching posters/<id>.html and rasterising it in the
* browser (library/packet/poster-raster.js). Print was the odd one out
* until 2026-09-24 — the same poster downloaded fine and PRINTED as
* "Poster not available yet." bundlePosters:true or an assetsBase that
* serves posters/ still short-circuits the fallback with the shipped webp.
* posters/<id>.html — the poster SOURCE, what the fallback rasterises. Also not shipped by
* default (28 zones, 27.9MB raw), so on a drop with neither file the
* preview still refuses to open rather than printing a blank sheet.
* The @error fallback to a labelled placeholder is still live and still the right behaviour —
* it now covers the missing-poster case rather than the nothing-generated-yet case.
* CROSS-PANE COMMS — unchanged and still the decoupling that matters: this wall NEVER imports the
* map. The shared component dispatches the window CustomEvent named below, scoped by instanceId,
* so several embeds on one page never cross-talk. The shell listens and drives the map pane.
*
* ASSETS. previews/<id>.webp (~65KB, 378 generated) SHIP and are what the wall paints. Full
* posters/<id>.webp do NOT ship by default (~42MB); export and print fall back to fetching
* posters/<id>.html and rasterising it in the browser. See
* IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF.
*/
import { computed, inject, ref, watch, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { usePosterPrints } from '@map/composables/usePosterPrints.js'
import { openPrintFrame } from '@map/composables/printInPlace.js'
import { useI18n } from 'vue-i18n'
// Export-modal geometry — see the note at .modal-scrim below, and library/utils/viewportScrim.js.
import { useViewportScrim } from '@map/utils/viewportScrim.js'
import { computed, inject, ref } from 'vue'
import PosterWall from '@map/components/poster/PosterWall.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.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).
// The SAME normalize+evaluate pipeline the sibling map profile uses to turn the live
// /people-groups/list payload + a list's `selector` into the exact rows that belong to it.
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))
const instanceId = computed(() => profileConfig.value?.instanceId || 'upg-100-wall')
const isDark = computed(() => profileConfig.value?.isDark === true)
// Assets resolve RELATIVE to the host page so the tree stays drop-portable —
// file://, a CDN subpath, Railway and Capacitor all behave identically.
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
// Full-size print poster. `.webp` is the ONLY format that exists: data/posters/ holds 378 .webp
// and ZERO matching .png. This helper previously said `.png` and was UNCALLED — printPoster() and
// exportPoster() each built the same URL inline — so the wrong extension was invisible, sitting
// ready to break whoever "tidied up" by routing a call through it. Now it is the single source of
// truth for both, and a format change has ONE place to happen.
//
// NOTE the full-size posters are NOT shipped in the default tree (42MB): see
// IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF. This URL 404s unless the publisher sets
// bundlePosters:true or points assetsBase at a host that serves posters/. Both the export and the
// print-preview paths degrade honestly when it does (export reports an error; the preview window
// 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).
// Config-only, fail-closed API base — SAME resolution as the sibling map profile. No default
// host: a dev harness that never sets window.MAP_APP_API_URL simply gets no PG-level data, and
// the shared wall hides the row-dependent buttons rather than failing at click time.
const API_BASE = (typeof window !== 'undefined' && window.MAP_APP_API_URL)
|| import.meta.env.VITE_API_BASE_URL
|| ''
@ -125,6 +97,12 @@ const REGION_OF = {
// section combined — so collapsing them hides the two largest prayer fields in
// the dataset behind a single continental label. Matched by id prefix below.
}
// ⚠ TAKES AN ID, while the shared wall's groupBy takes the whole ITEM. The template
// adapts it — `:group-by="(item) => regionFor(item.id)"` — rather than changing this
// signature, because the id is what the taxonomy is keyed by and every prefix rule
// below reads it directly. Passing regionFor straight in built and loaded cleanly and
// then threw `c.startsWith is not a function` at first render: the whole wall was blank,
// 0 cards, with nothing wrong at the source layer.
const regionFor = (id) => {
if (REGION_OF[id]) return REGION_OF[id]
if (id.startsWith('asia-india-')) return 'India'
@ -134,305 +112,14 @@ const regionFor = (id) => {
if (id.startsWith('asia-china-')) return 'China + Mongolia'
return 'Other'
}
// One flat list from the three cluster groups — the same 27 the map paints.
const lists = computed(() => [
...(clusters.lists || []),
...(clusters.asia_lists || []),
...(clusters.uncovered_lists || []),
const items = computed(() => [
...(clusterLists || []),
...(clusterAsiaLists || []),
...(clusterUncoveredLists || []),
].filter(l => l && l.id))
// Sections ordered by TOTAL people groups (largest first), and cards within a
// section by their own count — so the densest prayer need leads at both levels.
const sections = computed(() => {
const byRegion = new Map()
for (const l of lists.value) {
const r = regionFor(l.id)
if (!byRegion.has(r)) byRegion.set(r, [])
byRegion.get(r).push(l)
}
return [...byRegion.entries()]
.map(([name, items]) => ({
name,
items: items.slice().sort((a, b) => (b.count || 0) - (a.count || 0)),
total: items.reduce((n, x) => n + (x.count || 0), 0),
}))
.sort((a, b) => b.total - a.total)
})
// The card you most recently sent to the map stays marked, so scrolling back down
// tells you where you left off rather than losing your place in 27 cards.
const lastShown = ref(null)
const failed = ref({})
const onImgError = (id) => { failed.value = { ...failed.value, [id]: true } }
function showOnMap(list) {
// TOGGLE, not a one-way set. `lastShown` only ever assigned, so the green
// highlight could MOVE between cards but never switch off — a control the user
// can turn on but not off reads as broken even when the selection is correct.
// Clicking the same card again clears it AND tells the map to deselect, so the
// card and the map can never disagree about what is selected.
const isOn = lastShown.value === list.id
lastShown.value = isOn ? null : list.id
// Scoped by instanceId so multiple embeds never cross-talk.
window.dispatchEvent(new CustomEvent('doxa-upg-100-list:show-on-map', {
detail: {
instanceId: instanceId.value,
listId: isOn ? null : list.id, // null = deselect
label: isOn ? null : list.label,
},
}))
}
// ─── PREVIEWS — bundled, static, instant ─────────────────────────────────────
// These are DOWNSCALES OF THE REAL POSTERS (the SVG generator's output: title,
// all people-group names de-overlapped into two gutters with leader lines, the
// Mark 6:40 footer), not a live map screenshot.
//
// The wall previously rendered each card by spinning up a hidden Mapbox map,
// waiting for tiles, and capturing the canvas. That was 2-3s PER CARD and
// produced a near-white basemap with NO NAMES — which for a poster whose whole
// purpose is praying for groups by name is the wrong artefact, not merely a
// slow one. Serving pre-rendered previews fixes both at once: no map, no GPU,
// no network per card.
//
// PREVIEWS ARE SERVED FROM assetsBase, NOT INLINED INTO THE BUNDLE.
//
// They used to be `import.meta.glob(..., eager: true)`, which base64-inlines them.
// That was right at 13 previews (400x533 = 129KB) and wrong at 28: the v3 template
// previews are 560x560 and total ~1.8MB, which took the bundle to 6MB. And because
// the build is deliberately a single-file IIFE (`inlineDynamicImports: true`, so
// the bundle stays drop-portable), the map, the list view AND the poster wall all
// wait on ALL of it. That is the reported symptom exactly — "maps aren't loading
// fast enough, and while I'm waiting I should be able to go to list view or poster
// view but it's broken": the view switcher is not broken, it simply does not exist
// yet while 6MB parses.
//
// `eager: false` does NOT help here — assetsInlineLimit is MAX_SAFE_INTEGER, so
// every asset inlines regardless. Serving them from assetsBase — the same
// convention posterUrl() already uses — keeps the bundle small AND keeps the tree
// drop-portable (file://, CDN subpath, Railway and Capacitor all behave the same).
// Each card then fetches one ~65KB WebP lazily, in parallel, only when scrolled to.
const previewUrl = (id) => `${assetsBase.value}/previews/${id}.webp`
// Which previews failed to load, so a card can fall back to its text placeholder.
const missing = ref({})
// ─── EXPORT (full size) ──────────────────────────────────────────────────────
// ★ THE HIDDEN-MAP EXPORT PIPELINE WAS REMOVED HERE, 2026-09-24, as DEAD CODE:
// renderFor() had ZERO call sites and was the only caller of makeHiddenMap() and
// loadPoster(); EXPORT_SPEC was never read. It dated from when this wall rendered
// its own poster by spinning up an off-screen gl.Map and compositing tiles. Export
// and print now take the shipped .webp, or rasterise posters/<id>.html in the
// browser (library/packet/poster-raster.js) — no map instance is involved.
//
// ⚠ IT WAS NOT MERELY UNUSED. isMapLeaf() in generate-tree.mjs is a REGEX over the
// file text (MAP_ENGINE_RE), so the `window.mapbox` + `gl` engine handle the removed
// function used — spelled here in two pieces ON PURPOSE, because writing it whole
// would re-flag this profile as a map and reinstate the very bug being described —
// unreachable function — flagged this profile isMap=true. That flag in turn made
// verify-map-paint.mjs EXCLUDE the profile, with a written justification that it
// 'constructs a real gl.Map ON DEMAND during poster export'. True of the source,
// false of the running product: the profile was excluded from a paint gate on the
// strength of code that never ran. Deleting it makes the flag tell the truth.
const exportState = ref(null) // { label, phase, message, pct, kind }
// Blob URLs minted for a rasterised print preview. They CANNOT be revoked when the
// print window opens — the preview <img> is still loading from them, and revoking
// early gives a blank sheet — so they are held until this element goes away.
// One poster is ~1.5MB as a blob; a reader printing a dozen in one session would
// otherwise pin ~18MB for the life of the page.
const printObjectUrls = []
onBeforeUnmount(() => {
for (const u of printObjectUrls.splice(0)) { try { URL.revokeObjectURL(u) } catch { /* already gone */ } }
})
// CARD STOCK for the prayer-card deck. The Driver asked to be able to "choose the
// layout of the print dialog to choose the aspect ratio of these prayer cards", so
// the stock is a user choice, not a constant. Poker (2.5x3.5in) is the default: it
// is what commercial card printers, sleeves and boxes are built around.
// @page takes these as explicit inches — a NAMED size silently falls back to letter
// in Chromium, which is the exact bug this deck is being fixed for.
// Both orientations, because the Driver asked for "horizontal cards" as well as
// vertical ones. A landscape stock is not merely transposed: the renderer moves
// the photo BESIDE the text, since a 46%-height band on a 2.5in-tall card is too
// short to carry a face. The labels come from the renderer's own STOCK table so
// this list cannot drift from what the renderer actually supports.
const DECK_STOCKS = [
{ id: 'poker', label: 'Card 2.5 × 3.5 in' },
{ id: 'pokerLandscape', label: 'Card 3.5 × 2.5 in (landscape)' },
{ id: 'postcard', label: 'Postcard 4 × 6 in' },
{ id: 'postcardLandscape', label: 'Postcard 6 × 4 in (landscape)' },
{ id: 'a6', label: 'A6 105 × 148 mm' },
]
const deckStock = ref('poker')
// The deck follows the app's locale; packet-strings.json falls back PER KEY, and
// the QR on each card encodes the LOCALISED profile URL, so a Spanish card scans
// through to the Spanish page.
const { locale: deckLocale } = useI18n()
function deckLang() {
const l = deckLocale && (deckLocale.value ?? deckLocale)
return (typeof l === 'string' && l) || 'en'
}
const { scrimRef, scrimStyle } = useViewportScrim(exportState)
// 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 MECHANISM ───────────────────────────────────────────────────
// ONE mechanism, reused by all three "Print …" actions (poster / list / card
// deck) rather than three ad-hoc implementations.
//
// `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).
//
// ── HISTORY, kept because the lesson outlives the code that taught it ──
// This helper used to call window.open(). An earlier build passed `noopener`, which makes
// window.open() return null BY SPECIFICATION — severing the opener relationship is exactly what
// the flag does, so there was no handle to write into. Every click silently fell through to a
// raw-content fallback, for every user. MEASURED: window.open('','_blank','noopener') -> null;
// the identical call without the flag -> a usable handle. It was first blamed on the headless box;
// it was not environmental, it was what the flag means.
// The iframe below makes that class of failure unreachable — there is no popup to block and no
// opener flag to get wrong — but the lesson stands: a flag that returns null by spec will look
// exactly like an environment problem.
// ★ IN-PLACE (2026-09-23) — a hidden same-page IFRAME, not a popup window.
//
// WHY THIS CHANGED, so nobody "restores" the popup as a regression fix: an EARLIER Driver ask
// wanted a preview window ("show the poster in your browser to help you print it"), and this
// helper was built around window.open() to serve it. The Driver has now REVERSED that: the print
// dialog must open DIRECTLY, with no second browser window. That is a decision change, not a bug.
//
// An iframe is the right vehicle because it keeps the ENTIRE existing contract: callers still pass
// one full <html> document string, we still document.write() it, and the markup's own
// `<img onload> -> window.print()` still fires — only now `window` inside that document is the
// iframe's contentWindow, so the dialog belongs to THIS page. All three print paths (poster /
// list / card deck) stay on this one mechanism.
//
// It also deletes a whole failure mode: there is no popup blocker on an iframe, so the
// "popup blocked -> fall back to opening the raw image" branch every caller carried is now dead.
// The returned object keeps a truthy `.document`-less shape only so existing `if (!w)` guards stay
// harmless; callers should no longer rely on a popup-blocked null.
// ★ ONE FRAME MECHANISM, not a fourth copy. This was a local reimplementation of
// library/composables/printInPlace.js — same off-screen iframe, same doc.write,
// same autoprint script, differing only in an unused `title` argument. Four copies
// of one mechanism is how a fix to the blank-first-page behaviour reaches one
// caller and not the others. The shared helper takes (bodyHtml, opts), so the
// title is dropped here: it was never used, the document's own <title> carries it.
const openPrintWindow = (title, bodyHtml, opts) => openPrintFrame(bodyHtml, opts);
// 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
// print dialog, so the user can PREVIEW the poster in the browser and print it — not
// just get a file. (Driver: "clicking a poster should open a print dialog and show the
// poster in your browser to help you print it.") Download stays available as a fallback
// (the export button), and if the popup is blocked we fall back to opening the image tab.
async function printPoster(list) {
if (exportState.value) return // one at a time (shares the HUD)
exportState.value = { label: list.label, kind: 'Poster', phase: 'fetch',
message: 'preparing print preview', pct: 30 }
try {
let url = posterUrl(list.id)
// ── THE SAME FALLBACK LADDER THE DOWNLOAD PATH ALREADY CLIMBS ───────────
// ★ THE ASYMMETRY THIS FIXES. posters/*.webp is NOT shipped by default (42MB;
// bundling it pushed the tree to 80MB, git-packed to 57MiB, and the catalog
// rejected the push). The DOWNLOAD path handles that: PosterWall.vue fetches
// posters/<id>.html and rasterises it in the browser. The PRINT path did not —
// it handed the <img> a url it never checked and, on 404, replaced the whole
// preview with "Poster not available yet."
// So on a default drop the same poster DOWNLOADED fine and PRINTED as an error
// message. The card that recorded this (IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-
// TRADEOFF) calls it out precisely: "a print preview that opens on 'Poster not
// available yet' is a worse first impression than a failed download."
//
// Probing costs one HEAD-like GET that is almost always a cache hit when the
// image IS shipped, and only on a miss do we pay for the HTML.
let res = null
try { res = await fetch(url) } catch { /* offline/blocked — treated as a miss */ }
if (!res || !res.ok) {
exportState.value = { ...exportState.value, phase: 'render',
message: 'generating the poster', pct: 55 }
const htmlRes = await fetch(`${assetsBase.value}/posters/${list.id}.html`)
// Still honest when there is genuinely nothing to render: the preview only
// opens once we HAVE a poster, so the reader never sees a blank print dialog.
if (!htmlRes.ok) throw new Error(`poster not available (${htmlRes.status})`)
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
const blob = await rasterPoster(await htmlRes.text(), { scale: 2 })
// ⚠ An SVG loaded through <img> is an isolated document: a lost stylesheet makes
// every fill fall back to BLACK, with no error and a normal file size. Printing
// a black square is worse than failing, so verify pixels before opening the
// dialog — the same guard the download path uses.
if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank')
url = URL.createObjectURL(blob)
printObjectUrls.push(url)
}
// Preview + print in a dedicated window: a single centered, page-fit image and an
// 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.
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>' +
// PORTRAIT, explicitly. `size:auto` let the browser choose, and it chooses LANDSCAPE for a
// square: the poster art is 3ft x 3ft (aspect-ratio:1/1, see the wall template below), so an
// auto page has no reason to prefer the tall axis. The list and card-deck print paths in this
// same file already pin `size:letter portrait`; the poster path was the odd one out.
// (Driver: "print to a vertical piece of printer paper".)
'<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}' +
// A SQUARE on a PORTRAIT sheet must fit by its SHORTER axis. The old print rule
// (`max-height:none;width:100%`) scaled the square to the full page WIDTH, which on portrait
// letter is taller than the printable height -> the poster overflowed onto a second page.
// Constrain BOTH axes and let object-fit letterbox it inside the sheet instead.
'@media print{body{height:auto}' +
'img{width:auto;max-width:100%;max-height:100%;object-fit:contain}}</style></head>' +
'<body><img src="' + url + '" alt="' + title + '"' +
' 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>')
if (!w) {
// No longer "popup blocked" — an iframe cannot be blocked. This is now only reachable if the
// frame has no contentDocument at all (a sandboxed/detached host), so the honest fallback is
// the poster itself rather than a pop-up instruction the user cannot act on.
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 dialog opened', 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 }
}
}
// ─── 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.
// ─── THE RECORDS PROVIDER — the one thing that cannot be shared ──────────────
async function fetchListRecords(list) {
if (!API_BASE) throw new Error('no API configured')
// image_url + primary_language are what make a PRAYER CARD rather than a data
@ -462,429 +149,31 @@ async function fetchListRecords(list) {
.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) {
// Not a pop-up blocker any more (this prints via a same-page iframe). Only a host that
// denies us a contentDocument lands here, so say something the user can act on.
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
message: 'Could not open the print dialog in this view — try the Download button.', pct: 0 }
return
}
exportState.value = { ...exportState.value, phase: 'done', message: 'print dialog 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 }
}
}
// The wall's own copy. v-html in the shared component, so the <strong> lands as markup —
// the emphasis is the point: praying for them BY NAME is the whole brief.
const intro = 'Hand away the prayer task in clusters of fifties and hundreds — the way Jesus fed '
+ 'the 5,000 (Luke 9:14). Each poster is a 100-list you can pray, print, and put on a wall — '
+ 'praying for them <strong>by their people group name</strong>.'
// 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.
// ── CARD DECK: the SHARED action ────────────────────────────────────────────
// This function used to be a second, hand-maintained copy of the deck print —
// same renderer call, same stock, same language resolution — living beside the
// composable's. Two copies of one fact drift: a fix to either was invisible to
// the other, which is the whole reason the wall was being consolidated.
//
// It now delegates to library/composables/usePosterPrints.js, sharing THIS wall's
// exportState so the HUD keeps rendering one surface. The bundle still owns the
// part that cannot be shared — fetchListRecords knows the people-groups API, the
// selector evaluator and the pg_slugs special case — and hands it over as the
// `records` provider.
const prints = usePosterPrints({
state: exportState,
records: (list) => fetchListRecords(list),
lang: () => deckLang(),
stock: () => deckStock.value,
label: (list) => list?.label || list?.id || '',
})
async function printCardDeck(list) {
await prints.printCardDeck(list)
// Keep the preview-specific hint the shared action cannot know about: in a
// dropped tree with no API configured the failure is a CONFIGURATION problem,
// not a broken button, and saying so is the difference between "this is broken"
// and "this needs a URL".
const st = exportState.value
if (st && st.phase === 'error' && !API_BASE && !/MAP_APP_API_URL/.test(st.message)) {
exportState.value = { ...st,
message: `${st.message} (no API configured in this preview — set window.MAP_APP_API_URL)` }
}
}
function dismissExport() { exportState.value = null }
useShadowStyles(
'.wall{position:absolute;inset:0;overflow-y:auto;padding:22px 18px 44px;' +
"background:#f6f7f9;color:#0f1419;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}" +
'.wall.is-dark{background:#0a1410;color:#e5e7eb}' +
'.wall-h{margin:0 0 6px;font-size:19px;font-weight:700;text-align:center}' +
// Section tag — a small pill under the heading naming the mobilization track
// this wall belongs to, matching the nav section it sits under.
'.wall-tag{margin:0 auto 12px;width:fit-content;padding:3px 11px;border-radius:999px;' +
'background:rgba(16,185,129,.13);color:#0b7a5b;border:1px solid rgba(16,185,129,.28);' +
'font-size:10.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase}' +
'.is-dark .wall-tag{background:rgba(16,185,129,.16);color:#6ee7b7;border-color:rgba(110,231,183,.32)}' +
'.wall-sub{margin:0 auto 22px;max-width:620px;font-size:12.5px;line-height:1.55;' +
'text-align:center;opacity:.75}' +
// The bolded phrase must not be dimmed by the paragraph opacity, or the emphasis
// is lost — lift it back to full weight and full contrast.
'.wall-sub strong{font-weight:700;opacity:1;color:inherit}' +
// The grid: auto-fill so it reflows from 1 column on a phone to 4+ on a desktop
// without a single breakpoint.
// GAP must clear the OVERHANGING TAPES, not just the card boxes. The header tape
// sits at top:-11px and the footer fieldset at bottom:-14px, so a 26px row gap
// left only ~1px of real air between one card's footer and the next card's
// header — which is why the wall read as one dense block rather than a set of
// separate posters. 26 -> 64 vertical gives ~39px of true breathing room.
// Column gap widens too: posters need margin to read as individual objects.
'.grid{display:grid;gap:64px 30px;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));' +
'max-width:1180px;margin:0 auto}' +
// A card is a poster taped to a wall: the tape strips are NARROWER than the card
// and overlap its edges, which is what sells the effect.
// Interior padding is the poster's MOUNT — the white border a framed print sits
// in. Widened top/bottom so the artwork is not crowded by its own tapes.
'.card{position:relative;background:#fff;border:1px solid #e3e6ea;border-radius:3px;' +
'padding:24px 12px 26px;box-shadow:0 1px 3px rgba(0,0,0,.10),0 6px 18px rgba(0,0,0,.07);' +
'transition:transform .16s ease,box-shadow .16s ease}' +
'.card:hover{transform:translateY(-3px);box-shadow:0 3px 8px rgba(0,0,0,.14),0 12px 28px rgba(0,0,0,.10)}' +
'.is-dark .card{background:#132019;border-color:rgba(110,231,183,.20)}' +
// box-sizing is NOT inherited into a shadow root from the host page's reset, so
// without this the fieldset's padding+border ADD to its 82% while the header
// button's do not — the two strips came out 10px apart despite the same width.
'.tape{box-sizing:border-box;position:absolute;left:50%;transform:translateX(-50%);z-index:2;' +
'font-size:10.5px;font-weight:600;letter-spacing:.02em;cursor:pointer;' +
'border:1px solid rgba(0,0,0,.08);background:#eef2f6;color:#23303c}' +
'.is-dark .tape{background:#1d2b23;color:#cfe9d9;border-color:rgba(255,255,255,.10)}' +
// ★ nowrap: a 3-DIGIT COUNT MUST NOT MAKE THE PILL TWO LINES TALL (2026-09-23).
// The tape is a fixed 82% of the card, so "116 UPG · Show on map" wrapped while
// "92 UPG · Show on map" did not — MEASURED on the shipped wall at 1440px, the same
// pill rendered h=34 for 3-digit counts and h=22 for 2-digit ones, a 12px step that
// reads as two different components sitting side by side in one row.
// The sibling footer pill (.tape-l, below) already carries white-space:nowrap, so this
// was an inconsistency inside one component rather than a design question — and
// .hcount's font-variant-numeric:tabular-nums shows the author already wanted digit
// width not to move things. Wrapping undid that intent.
'.tape-h{top:-11px;width:82%;padding:4px 8px;border-radius:999px;white-space:nowrap;' +
'display:flex;align-items:center;justify-content:center;gap:5px}' +
'.tape-h:hover{background:#e2e8ee}' +
// The COUNT leads the header: it is the one fact that distinguishes one list
// from another at a glance, and the reason to pray this one.
// "92 UPG" — the unit is carried WITH the number so the header states what it
// counts. Bold because it is the fact that distinguishes one card from the next.
'.hcount{font-weight:800;font-variant-numeric:tabular-nums}' +
'.hsep{opacity:.45}' +
'.hlabel{font-weight:600}' +
// A card you sent to the map stays marked, so scrolling back down tells you
// where you left off instead of losing your place among 27 cards.
'.card.is-last{box-shadow:0 0 0 2px #10b981,0 6px 18px rgba(0,0,0,.10)}' +
'.card.is-last .tape-h{background:#10b981;color:#04241a;border-color:#10b981}' +
// FOOTER: a pill that opens two explicit print choices. "Prayer packet" was
// replaced because it did not say what you would actually get.
// FOOTER FIELDSET — mirrors the header's Select View control (a real
// <fieldset> + <legend> with the legend sitting on the top border line), so the
// card footer and the app header speak the same design language.
// SQUARED edges (not the rounded pill it replaced), and the SAME 82% width as
// the header tape so the two strips align as a matched pair top and bottom.
// Still narrower than the card itself, so its edge frames the artefact.
// Card footer WRAPS like text: two buttons sit side by side when they fit, one
// per line when they do not, and NEITHER stretches to fill the row just because
// it is alone on its line (2026-09-23). `flex:1` + `white-space:nowrap` on .pbtn
// used to force every button to the row's full width AND forbid its label from
// wrapping — together, one very wide button per line no matter how short the
// label. Three buttons went from short filler text to real labels ("Print a
// UPG-100 list" / "Print a UPG prayer poster" / "Print a UPG card deck"), which
// is what made the latent one-button-per-row problem visible. `.pfs` is no
// longer a flex row; it wraps, and each `.pbtn` sizes to its own content instead
// of the row.
// ★ SIT ON THE CARD'S EDGE, NOT ON THE ARTWORK (Driver, 2026-09-23: "it's overlapping the poster
// too much... it's supposed to be near the edge, slightly overlapping the white edge, but NOT the
// blue outline preview").
//
// THE MEASUREMENT THAT MISLED ME FIRST: at bottom:-14px the fieldset's top was 12px above the
// image element's bottom, and `img{object-fit:contain}` made "12px inside the image box" LOOK like
// letterboxed white margin. It is not — these posters fill the card's WIDTH, so contain letterboxes
// at the SIDES, and the bottom 12px is real artwork. Reading the screenshot showed the legend
// printed across the poster's own footer text. Geometry said white space; the image said otherwise.
//
// bottom:-26px clears the artwork: the fieldset's top now lands below the image box, so only its
// top border grazes the card edge — "slightly overlapping the white edge" as asked. The negative
// offset is kept (rather than moving the row into flow) because the tape-header at top:-11px uses
// the same overhang idiom, and the 26px row gap noted at :542 was sized for this pairing.
// ★ TWO BUTTONS PER LINE, NEVER THREE LINES (Driver, 2026-09-23, second pass on this row):
// "nothing should overlap that blue outline... it shouldn't have one option per line, because
// that's 3 lines of height — increase the width of the print options slightly so you can fit two
// per line." The mechanism that made 3 lines overlap the outline: this fieldset is anchored
// bottom:-26px and GROWS UPWARD as content wraps, so every extra line pushes its top further into
// the image — and the blue hover outline lives on the image (.img.is-clickable:hover, line ~661).
// At width:82% the three labels fell one per line; 96% lets two share a line, so the box stays
// two lines tall and its top stays below the artwork. Same overhang idiom, wider row.
'.pfs{position:absolute;bottom:-26px;left:50%;transform:translateX(-50%);' +
'width:96%;margin:0;padding:0 3px 3px;border:1px solid rgba(0,0,0,.14);' +
'border-radius:2px;background:#fff;display:flex;flex-wrap:wrap;gap:1px 2px;' +
'justify-content:center;box-shadow:0 1px 4px rgba(0,0,0,.10)}' +
'.is-dark .pfs{background:#132019;border-color:rgba(110,231,183,.24)}' +
'.pfs > legend{margin:0 auto;padding:0 5px;font-size:8px;text-transform:uppercase;' +
'letter-spacing:.6px;font-weight:700;color:#8a93a0}' +
'.is-dark .pfs > legend{color:#7f9c8c}' +
// Buttons mirror .seg button: text-only with an underline accent on hover,
// not filled chips — the same restraint the header uses. Content-sized (no
// flex:1), with a max-width so one very long label still wraps to two lines
// rather than pushing the button wider than the card.
'.pbtn{position:relative;flex:0 1 auto;max-width:100%;border:0;background:none;' +
'color:#5b6672;font:inherit;font-size:10.5px;font-weight:600;padding:2px 6px 3px;' +
'cursor:pointer;white-space:normal;text-align:center;line-height:1.25;' +
'transition:color .15s}' +
'.pbtn::after{content:"";position:absolute;left:6px;right:6px;bottom:0;height:2px;' +
'border-radius:2px;background:transparent;transition:background .15s}' +
'.pbtn:hover{color:#10b981}' +
'.pbtn:hover::after{background:#10b981}' +
// CARD STOCK picker — sits with the print buttons because it changes what the
// print dialog PRODUCES (it becomes the @page size). Quieter than the buttons:
// it is a modifier, not an action.
'.pstock{flex:0 1 auto;max-width:100%;border:1px solid #d8dde3;border-radius:3px;' +
'background:#fff;color:#5b6672;font:inherit;font-size:9.5px;font-weight:600;' +
'padding:2px 4px;cursor:pointer;line-height:1.25}' +
'.pstock:hover{border-color:#10b981;color:#10b981}' +
'.is-dark .pstock{background:#12211a;border-color:#2c4a3b;color:#9fb3a8}' +
'.is-dark .pbtn{color:#9fb3a8}' +
// SECTIONS.
// Sections need more separation than the cards within them, or the regional
// grouping stops reading as a grouping.
'.sec{max-width:1180px;margin:0 auto 56px}' +
'.sec-head{display:flex;align-items:baseline;gap:10px;margin:0 2px 14px;' +
'padding-bottom:7px;border-bottom:1px solid rgba(0,0,0,.10)}' +
'.is-dark .sec-head{border-bottom-color:rgba(255,255,255,.12)}' +
'.sec-name{margin:0;font-size:15px;font-weight:700;letter-spacing:.01em}' +
'.sec-count{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}' +
// Poster aspect is 3ft x 3ft SQUARE — the real print ratio of the v3 template,
// so the wall previews the actual artefact rather than an arbitrary box.
// (Was 2/3 for the old 24x36 portrait sheets.)
'.main{position:relative;aspect-ratio:1/1;border:1px solid #dfe3e7;overflow:hidden;' +
'background:#fbfcfd;display:flex;align-items:center;justify-content:center}' +
'.is-dark .main{background:#0c1712;border-color:rgba(255,255,255,.08)}' +
// CONTAIN, not cover. `cover` crops to fill — on a square sheet in a portrait
// box that sliced off the title and the footer scripture, which are two of the
// three things the template is built to make readable.
'.img{width:100%;height:100%;object-fit:contain;display:block}' +
'.img.is-clickable{cursor:zoom-in}' +
'.img.is-clickable:hover{outline:2px solid #38bdf8;outline-offset:-2px}' +
'.img.is-clickable:focus-visible{outline:2px solid #38bdf8;outline-offset:-2px}' +
'.pbtn-print{font-weight:600}' +
'.ph{padding:14px 10px;text-align:center}' +
'.ph-label{font-size:12.5px;font-weight:700;line-height:1.3}' +
'.ph-count{margin-top:5px;font-size:11px;opacity:.7}' +
'.ph-note{margin-top:10px;font-size:9.5px;text-transform:uppercase;letter-spacing:.09em;opacity:.45}' +
// Toast for the not-yet-wired actions.
// Per-card rendering spinner.
'.spinner{width:22px;height:22px;margin:0 auto 8px;border:2px solid rgba(0,0,0,.12);' +
'border-top-color:rgba(0,0,0,.45);border-radius:50%;animation:upgspin .8s linear infinite}' +
'.is-dark .spinner{border-color:rgba(255,255,255,.16);border-top-color:rgba(255,255,255,.55)}' +
'@keyframes upgspin{to{transform:rotate(360deg)}}' +
// EXPORT MODAL. The intent is right — the render blocks the user's intent, not merely this
// pane — but the old reasoning here was FALSE and worth naming, because it reads as reassuring:
// it said "position:fixed inside a shadow root is still viewport-relative, so this covers the
// whole app". The premise is TRUE and IRRELEVANT. A shadow boundary scopes styles and
// selectors; it does NOT establish a containing block. contain:layout|paint on an ANCESTOR
// does — and VerticalTabsLayout sets exactly that on .vtl-content, so in the cap-5 shell this
// scrim covered the PANE only: MEASURED 1024x793 @ (256,107) at a 1280x900 viewport, leaving
// the 256px rail and 107px header uncovered and CLICKABLE behind a "modal".
// GEOMETRY IS NOW SET INLINE by useViewportScrim (it measures the real origin and subtracts it;
// verified 0,0 1280x900). The declarations below are the LOOK only — do not re-add geometry,
// and do not re-derive the shadow-root reassurance: it answers a question nobody asked.
'.modal-scrim{position:fixed;inset:0;z-index:1000;background:rgba(8,12,16,.55);' +
'display:grid;place-items:center;padding:22px}' +
'.modal{width:min(340px,100%);background:#fff;color:#0f1419;border-radius:14px;' +
'padding:20px 22px;box-shadow:0 10px 40px rgba(0,0,0,.35);text-align:center}' +
'.is-dark .modal{background:#132019;color:#e5e7eb}' +
'.modal-kind{font-size:10px;text-transform:uppercase;letter-spacing:.1em;opacity:.55}' +
'.modal-title{margin:3px 0 14px;font-size:15px;font-weight:700;line-height:1.3}' +
'.bar{height:5px;border-radius:99px;background:rgba(0,0,0,.10);overflow:hidden}' +
'.is-dark .bar{background:rgba(255,255,255,.12)}' +
'.bar-fill{height:100%;background:#10b981;border-radius:99px;transition:width .25s ease}' +
'.modal-phase{margin-top:10px;font-size:12.5px;font-weight:600}' +
'.modal-hint{margin-top:6px;font-size:11px;line-height:1.5;opacity:.62}' +
'.modal-soon{font-size:13px;line-height:1.5;color:#5f5849;margin:6px 0 14px}' +
'.modal-err{font-size:12.5px;line-height:1.55;color:#b42318;margin-bottom:14px}' +
'.is-dark .modal-err{color:#fda29b}' +
'.modal-btn{border:1px solid rgba(0,0,0,.14);background:transparent;color:inherit;' +
'font:inherit;font-size:12.5px;padding:6px 18px;border-radius:8px;cursor:pointer}' +
'.modal-btn:hover{background:rgba(0,0,0,.06)}',
'upg-poster-wall',
)
</script>
<template>
<div class="wall" :class="{ 'is-dark': isDark }">
<h2 class="wall-h">My UPG 100 List — Prayer Lists &amp; Posters</h2>
<div class="wall-tag">Prayer Mobilization</div>
<p class="wall-sub">
Hand away the prayer task in clusters of fifties and hundreds — the way Jesus fed the 5,000
(Luke 9:14). Each poster is a 100-list you can pray, print, and put on a wall —
praying for them <strong>by their people group name</strong>.
</p>
<!-- One block per region, ordered by total people groups. -->
<section v-for="sec in sections" :key="sec.name" class="sec">
<div class="sec-head">
<h3 class="sec-name">{{ sec.name }}</h3>
<span class="sec-count">{{ sec.total.toLocaleString() }} people groups · {{ sec.items.length }} {{ sec.items.length === 1 ? 'list' : 'lists' }}</span>
</div>
<div class="grid">
<div v-for="list in sec.items" :key="list.id" class="card"
:class="{ 'is-last': lastShown === list.id }" :data-list-id="list.id">
<!-- HEADER tape: the PG count + the map action, so a card says what it
holds and where it lives without opening anything. -->
<button type="button" class="tape tape-h"
:aria-label="`Show ${list.label} on the map`" @click="showOnMap(list)">
<span class="hcount">{{ list.count }} UPG</span>
<span class="hsep">·</span>
<span class="hlabel">Show on map</span>
</button>
<div class="main">
<!-- The placeholder is now driven by a LOAD FAILURE rather than by a
missing inlined asset: with previews served from assetsBase we do
not know a poster is absent until its fetch 404s. -->
<!-- Click (or Enter/Space) the poster to PREVIEW + PRINT it in the browser —
not a silent file download. (Driver: show the poster in the browser to
help print it.) The Download button below stays the secondary save path. -->
<img v-if="!missing[list.id]" class="img is-clickable" :src="previewUrl(list.id)"
:alt="`${list.label} — UPG 100 list prayer poster (click to preview and print)`"
loading="lazy" decoding="async" role="button" tabindex="0"
:title="`Preview & print the ${list.label} poster`"
@click="printPoster(list)"
@keydown.enter.prevent="printPoster(list)" @keydown.space.prevent="printPoster(list)"
@error="missing[list.id] = true" />
<div v-else class="ph">
<div class="ph-label">{{ list.label }}</div>
<div v-if="list.count" class="ph-count">{{ list.count }} people groups</div>
<div class="ph-note">poster not generated yet</div>
</div>
</div>
<!-- FOOTER: a real <fieldset> + <legend>, the SAME structure as the
header's Select View control, so the two read as one design language.
Both options are visible side by side — no menu to open, because on a
card the choice is the whole point and hiding it behind a click costs
a step for nothing. -->
<fieldset class="tape pfs">
<legend>Print Options</legend>
<!-- 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). -->
<!-- ★ THE LEGEND ALREADY SAYS "PRINT OPTIONS" (line above), so repeating "Print a" in
every button is three redundant words competing for the row's width. Labels are the
OBJECT only; the verb lives in the legend that frames them. The emoji goes for the
same reason — a printer glyph beside a "Print Options" legend adds no information
and costs horizontal space that pushed the third button onto its own line.
★ THE ARIA-LABEL KEEPS THE FULL SENTENCE. A screen-reader user does not get the
legend's framing as reliably as a sighted user reading down the fieldset, and a
control announced as "card deck" is ambiguous — so the visible label shortens while
the accessible name stays "Print the <list> UPG card deck". check:a11y-button-names
requires a name; this keeps it descriptive rather than merely present. -->
<button type="button" class="pbtn pbtn-print" @click="printList100(list)"
:aria-label="`Print the ${list.label} UPG-100 list`">UPG-100 list</button>
<button type="button" class="pbtn pbtn-print" @click="printPoster(list)"
:aria-label="`Print the ${list.label} UPG prayer poster`">UPG prayer poster</button>
<button type="button" class="pbtn pbtn-print" @click="printCardDeck(list)"
:aria-label="`Print the ${list.label} UPG card deck`">card deck</button>
<!-- CARD STOCK. The Driver asked to "choose the layout of the print
dialog to choose the aspect ratio of these prayer cards" — so the
size is picked here and becomes the @page size, which is what makes
the print dialog show CARDS instead of letter paper. -->
<select class="pstock" v-model="deckStock"
aria-label="Prayer card size"
@click.stop>
<option v-for="s in DECK_STOCKS" :key="s.id" :value="s.id">{{ s.label }}</option>
</select>
</fieldset>
</div>
</div>
</section>
<!-- EXPORT MODAL — reports the library's REAL render phase, not a bare spinner.
A poster render spins up a hidden map, waits for tiles, then composites;
on a phone that is several seconds and an unlabelled spinner reads as a
hang. Phase + message come straight from useMapPoster's progress ref. -->
<div v-if="exportState" ref="scrimRef" :style="scrimStyle" class="modal-scrim" @click.self="exportState.phase === 'error' && dismissExport()">
<div class="modal" role="dialog" aria-live="polite">
<div class="modal-kind">{{ exportState.kind }}</div>
<div class="modal-title">{{ exportState.label }}</div>
<template v-if="exportState.phase === 'error'">
<div class="modal-err">{{ exportState.message }}</div>
<button type="button" class="modal-btn" @click="dismissExport">Close</button>
</template>
<!-- COMING SOON — deliberately not styled as an error. -->
<template v-else-if="exportState.phase === 'soon'">
<div class="modal-soon">{{ exportState.message }}</div>
<button type="button" class="modal-btn" @click="dismissExport">Close</button>
</template>
<template v-else>
<div class="bar"><div class="bar-fill" :style="{ width: exportState.pct + '%' }"></div></div>
<div class="modal-phase">{{ exportState.message }}</div>
<div class="modal-hint">
Rendering a print-quality image in your browser — nothing is uploaded.
</div>
</template>
</div>
</div>
</div>
<PosterWall
:items="items"
:group-by="(item) => regionFor(item.id)"
:instance-id="instanceId"
:is-dark="isDark"
:assets-base="assetsBase"
event-name="doxa-upg-100-list:show-on-map"
heading="My UPG 100 List — Prayer Lists &amp; Posters"
tag="Prayer Mobilization"
:intro="intro"
unit="UPG"
item-noun="list"
:records="fetchListRecords"
:card-stocks="true"
poster-action="print"
:hide-prayer-list-when-soon="true"
download-prefix="doxa-upg-100"
/>
</template>

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-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+" 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-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+" 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-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+" 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-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ",
"integrity": "sha384-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+",
"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-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>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+\" 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-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>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+\" 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-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>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+\" 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>"
}
}
]
@ -658,7 +658,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "doxa-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm",
"integrity": "sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1",
"elements": [
"my-upg-100-list-map"
],
@ -670,7 +670,7 @@
{
"profile": "poster-wall",
"file": "poster-wall.vue",
"description": "A wall of printable poster cards, one per 100-list entry, each with a \"Show on map\" action that",
"description": "A wall of printable poster cards, one per 100-list entry, each with a \"Show on map\"",
"element": "my-upg-100-list-map",
"type": "single",
"isMap": false,
@ -678,7 +678,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -692,7 +692,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -706,7 +706,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]
@ -856,7 +856,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z",
"integrity": "sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL",
"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-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>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL\" 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-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>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL\" 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-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>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL\" 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>"
}
}
]

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,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-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1" 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-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1" 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-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1" 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 <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL" 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-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL" 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-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: