Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

This commit is contained in:
doxa-maps-88655c70 2026-09-25 17:58:31 +00:00
commit 42f48b7cf5
15 changed files with 448 additions and 235 deletions

View file

@ -297,7 +297,8 @@ export function useMapPoster(getSourceMap) {
async function _collectSlotSvgs(spec, layout) {
const out = [];
if (spec.slots?.title) out.push({ box: layout.title, svg: _stubTitleSvg(spec, layout.title) });
if (spec.slots?.legend) out.push({ box: layout.legend, svg: _stubLegendSvg(spec, layout.legend) });
// A legend with no rows is an empty white panel over the map: leave it out.
if (spec.slots?.legend?.rows?.length) out.push({ box: layout.legend, svg: _stubLegendSvg(spec, layout.legend) });
if (spec.slots?.scaleBar) out.push({ box: layout.scaleBar, svg: _stubScaleSvg(spec, layout.scaleBar) });
if (spec.slots?.north) out.push({ box: layout.north, svg: _stubNorthSvg(spec, layout.north) });
// Footer is ALWAYS present (attribution is mandatory).
@ -362,14 +363,25 @@ async function _spawnHiddenMap(sourceMap, w, h, opts = {}) {
// Some environments lock devicePixelRatio; fall back silently.
}
// Lazy-grab mapboxgl off the global — same pattern as useMapInstance.
// THE SAME ENGINE AS THE SOURCE MAP. This used to grab window.mapboxgl unconditionally, so on a
// keyless page (provider=maplibre) every poster threw "mapboxgl global not found" — the source map
// is a MapLibre map and no Mapbox engine is loaded at all. Measured 2026-09-25 while making the
// Simple Research export one click. Mapbox behaviour is unchanged.
// eslint-disable-next-line no-undef
const mapboxgl = (typeof window !== 'undefined' ? window.mapboxgl : null);
if (!mapboxgl) throw new Error('useMapPoster: mapboxgl global not found');
const W = (typeof window !== 'undefined' ? window : {});
const isMaplibre = !!W.maplibregl && sourceMap instanceof W.maplibregl.Map;
const gl = isMaplibre ? W.maplibregl : W.mapboxgl;
if (!gl) throw new Error('useMapPoster: ' + (isMaplibre ? 'maplibregl' : 'mapboxgl') + ' global not found');
const styleObj = sourceMap.getStyle();
// MapLibre's style spec wants projection as an OBJECT ({ type: 'globe' }); a live MapLibre map can
// hand back Mapbox's string spelling ('globe'), which MapLibre then refuses to load — measured
// 2026-09-25: every keyless poster failed with "projection: object expected, string found".
if (isMaplibre && styleObj && typeof styleObj.projection === 'string') {
styleObj.projection = { type: styleObj.projection };
}
const hiddenMap = new mapboxgl.Map({
const hiddenMap = new gl.Map({
container,
style: styleObj,
center: sourceMap.getCenter(),
@ -379,24 +391,38 @@ async function _spawnHiddenMap(sourceMap, w, h, opts = {}) {
interactive: false,
attributionControl: false,
preserveDrawingBuffer: true,
canvasContextAttributes: { preserveDrawingBuffer: true }, // MapLibre v5 spelling of the same
fadeDuration: 0,
});
// Style ready: 'style.load', or 'load', or already loaded by the time we look (an engine may finish
// a style OBJECT before a listener is attached). A style that cannot load says why ('error'), so a
// failed poster names its cause instead of a bare timeout.
let lastError = '';
await new Promise((resolve, reject) => {
let done = false;
const t = setTimeout(() => {
const finish = (err) => {
if (done) return;
done = true;
reject(new Error('useMapPoster: hidden map style.load timeout'));
}, IDLE_TIMEOUT_MS);
hiddenMap.once('style.load', () => {
if (done) return;
done = true;
clearTimeout(t);
resolve();
});
clearTimeout(t); clearInterval(poll);
err ? reject(err) : resolve();
};
const t = setTimeout(() => finish(new Error('useMapPoster: hidden map style.load timeout' + (lastError ? ' (' + lastError + ')' : ''))), IDLE_TIMEOUT_MS);
const poll = setInterval(() => { try { if (hiddenMap.isStyleLoaded()) finish(); } catch (_) { /* not yet */ } }, 100);
hiddenMap.once('style.load', () => finish());
hiddenMap.once('load', () => finish());
hiddenMap.on('error', (e) => { lastError = String(e?.error?.message || e?.message || e).slice(0, 160); });
});
// Print the place names in the language the reader sees. Mapbox localizes INSIDE the engine
// (setLanguage), so a new instance starts in the style default (English) — measured on an Arabic
// poster; carry the source map's language across. MapLibre's localized labels are expressions in
// the style itself, which the hidden map already copied.
try {
const srcLang = typeof sourceMap.getLanguage === 'function' ? sourceMap.getLanguage() : null;
if (srcLang && typeof hiddenMap.setLanguage === 'function') hiddenMap.setLanguage(srcLang);
} catch (_) { /* a label left in the default language is a nicety lost, never a failed export */ }
if (opts.preserveBounds && opts.bounds) {
hiddenMap.fitBounds(opts.bounds, { padding: 0, animate: false, duration: 0 });
}
@ -404,24 +430,30 @@ async function _spawnHiddenMap(sourceMap, w, h, opts = {}) {
return { hiddenMap, restoreDpr, container };
}
// The hidden map's 'idle' can arrive late or never on a heavy render (measured 2026-09-25: an
// Arabic Letter poster, RTL shaping + a 2x canvas, missed a 15s idle and the export gave NO file).
// So: at IDLE_TIMEOUT_MS, capture if every tile is in (something else is only repainting); keep
// waiting to IDLE_HARD_MS otherwise, then capture what is drawn with a warning. A poster whose last
// tiles are missing is still a file; a rejected promise is a dead Export button.
const IDLE_HARD_MS = 60000;
function _waitForIdle(map) {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
let settled = false;
const t = setTimeout(() => {
const timers = [];
const finish = (why) => {
if (settled) return;
settled = true;
reject(new Error('useMapPoster: hidden map idle timeout'));
}, IDLE_TIMEOUT_MS);
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(t);
timers.forEach(clearTimeout);
if (why) console.warn('[useMapPoster] ' + why);
requestAnimationFrame(() => resolve());
};
const tilesIn = () => { try { return typeof map.areTilesLoaded === 'function' && map.areTilesLoaded(); } catch (_) { return false; } };
timers.push(setTimeout(() => { if (tilesIn()) finish(); }, IDLE_TIMEOUT_MS));
timers.push(setTimeout(() => finish('hidden map not idle after ' + IDLE_HARD_MS / 1000 + 's; capturing what is drawn'), IDLE_HARD_MS));
if (map.loaded() && !map.isMoving()) {
finish();
} else {
map.once('idle', finish);
map.once('idle', () => finish());
}
});
}
@ -504,31 +536,49 @@ function mergeSignals(...signals) {
// These produce minimal SVG so the headless render still has *something* in
// each slot. Live preview uses the real Vue slot components instead.
// spec.dir === 'rtl' (an Arabic poster): text starts at the RIGHT edge and runs right-to-left.
// ⚠ Under direction="rtl" SVG's text-anchor is relative to the text's direction, so the right edge
// is "start", not "end" — "end" pushed every Arabic title off the poster (measured 2026-09-25).
function _rtl(spec) { return spec?.dir === 'rtl'; }
function _textAt(spec, box, inset) {
return _rtl(spec)
? `x="${box.w - inset}" text-anchor="start" direction="rtl"`
: `x="${inset}"`;
}
function _stubTitleSvg(spec, box) {
const t = (spec.slots?.title?.text || '').replace(/[<>&]/g, _esc);
const s = (spec.slots?.title?.subtitle || '').replace(/[<>&]/g, _esc);
return `
<svg xmlns="http://www.w3.org/2000/svg" width="${box.w}" height="${box.h}" viewBox="0 0 ${box.w} ${box.h}">
<rect width="100%" height="100%" fill="rgba(255,255,255,0.92)"/>
<text x="20" y="${box.h * 0.55}" font-family="Inter, system-ui, sans-serif"
<text ${_textAt(spec, box, 20)} y="${box.h * 0.55}" font-family="Inter, system-ui, sans-serif"
font-size="${Math.min(box.h * 0.5, 64)}" font-weight="700" fill="#0a0a0a">${t}</text>
${s ? `<text x="20" y="${box.h * 0.85}" font-family="Inter, system-ui, sans-serif"
${s ? `<text ${_textAt(spec, box, 20)} y="${box.h * 0.85}" font-family="Inter, system-ui, sans-serif"
font-size="${Math.min(box.h * 0.22, 28)}" fill="#444">${s}</text>` : ''}
</svg>`;
}
function _stubLegendSvg(spec, box) {
const rows = spec.slots?.legend?.rows || [];
const items = rows.slice(0, 24).map((r, i) => {
const y = 30 + i * 26;
const rows = (spec.slots?.legend?.rows || []).slice(0, 24);
const title = (spec.slots?.legend?.title || 'Legend').replace(/[<>&]/g, _esc);
const rtl = _rtl(spec);
// Type scales with the panel (it is ~600px wide at 200 DPI); the panel is only as TALL as its rows,
// so the rest of the column stays map.
const fs = Math.max(14, Math.round(box.w / 26)), line = Math.round(fs * 1.9), sw = Math.round(fs * 1.2), pad = Math.round(fs * 1.1);
const panelH = Math.min(box.h, pad * 2 + line * (rows.length + 1));
const swX = rtl ? box.w - pad - sw : pad;
const textX = rtl ? `x="${box.w - pad - sw - Math.round(fs * 0.7)}" text-anchor="start" direction="rtl"` : `x="${pad + sw + Math.round(fs * 0.7)}"`;
const items = rows.map((r, i) => {
const y = pad + line * (i + 1.5);
const c = (r.color || '#999').replace(/[<>&]/g, _esc);
const l = (r.label || '').replace(/[<>&]/g, _esc);
return `<g><rect x="14" y="${y - 14}" width="18" height="18" fill="${c}" stroke="#222" stroke-width="0.5"/><text x="42" y="${y}" font-family="Inter, sans-serif" font-size="14" fill="#111">${l}</text></g>`;
return `<g><rect x="${swX}" y="${y - sw + Math.round(fs * 0.15)}" width="${sw}" height="${sw}" rx="2" fill="${c}" stroke="#222" stroke-width="0.5"/><text ${textX} y="${y}" font-family="Inter, sans-serif" font-size="${fs}" fill="#111">${l}</text></g>`;
}).join('');
return `
<svg xmlns="http://www.w3.org/2000/svg" width="${box.w}" height="${box.h}" viewBox="0 0 ${box.w} ${box.h}">
<rect width="100%" height="100%" fill="rgba(255,255,255,0.94)" stroke="#ccc"/>
<text x="14" y="20" font-family="Inter, sans-serif" font-size="14" font-weight="700" fill="#111">Legend</text>
<rect width="${box.w}" height="${panelH}" fill="rgba(255,255,255,0.94)" stroke="#ccc"/>
<text ${_textAt(spec, box, pad)} y="${pad + line * 0.6}" font-family="Inter, sans-serif" font-size="${Math.round(fs * 1.1)}" font-weight="700" fill="#111">${title}</text>
${items}
</svg>`;
}

View file

@ -55,6 +55,8 @@
"check:embedded-rtl": "node internal/check-embedded-rtl.mjs",
"check:english-leaks": "node internal/check-gospel-english-leaks.mjs",
"check:experimental-shelf": "node internal/check-experimental-shelf.mjs",
"check:export-one-click": "node plugin-designs/doxa-research-map/scripts/check-export-one-click.mjs",
"check:export-one-click:maplibre": "node plugin-designs/doxa-research-map/scripts/check-export-one-click.mjs --provider maplibre",
"check:facet-cascade": "node internal/check-facet-cascade.mjs",
"check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline",
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
@ -245,6 +247,8 @@
"check:embed-targets": "every embed.iframe src in manifest.json must resolve to a file that EXISTS. Those snippets ARE deployment-method 1 (iframe) — a partner pastes them verbatim — and NOTHING else asserts their targets are still emitted, because every other gate walks only files that exist and is blind to ABSENCE. `the `check:embed-targets:self-test` script` poisons a copy of the manifest and asserts the gate NOTICES: a gate that cannot fail is worse than no gate.",
"check:english-leaks": "A translated key must not render in English. rega's method: a string is a LEAK when the catalogue has a DIFFERENT value for the locale and the page still shows the English one - a render/wiring fault, not a missing translation. Third-party attributions, data names, placeholder-only strings and the prose this bundle deliberately keeps in English are all excluded, the last by simply not being in the catalogue. Scoped to the LEAF on purpose: six keys were reported leaking in the gallery and the leaf is clean in ar/zh/ru/hi - the gallery builds its iframe src with no lang parameter, so the embed never learns the locale. Embedder defect, not a bundle one.",
"check:experimental-shelf": "Every Experimental-folder bundle, leaf + Gallery tab, mapbox + maplibre: mounts with content, 0 errors / failed requests, maps load and DRAW their own data. Self-tests on a deliberately broken bundle first. Run through internal/browser-slot.sh.",
"check:export-one-click": "the Simple Research 'Export poster' button gives a FILE on ONE click (no menu, no dialog, no second step): leaf AND Research Gallery, en AND ar; the file is a real print PNG (US Letter @200 DPI); 0 page errors. Run inside the shared browser slot. Exit 2 = SKIP, 75 = NOT MEASURED.",
"check:export-one-click:maplibre": "the same four one-click exports on the keyless MapLibre engine (OpenFreeMap tiles): the hidden print map must be built with the engine that drew the screen, and the footer must credit the tiles actually printed.",
"check:facet-cascade": "a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. run check:facet-cascade:self-test for the NEGATIVE test (reverts the cascade and requires this gate to FAIL).",
"check:facet-changes-map": "a facet selection must CHANGE THE DOTS — and the dots must SAY what they mean. Asserts (1) the rd-bubbles feature set changes by count AND identity, (2) every surviving dot satisfies the selected level, (3) while a prayer filter is active circle-color is a DATA-DRIVEN EXPRESSION reading peoplePraying, not a flat literal. Assertion 3 is the one that matters: the prayer filter ALREADY narrowed 2107->689 correctly while the Driver reported it broken, because every dot painted the same orange. Browser gate (needs `bun run dev` on 5173, or BASE_URL/PROBE_PATH at a served tree), so NOT in build:unlocked. run the `check:facet-changes-map:self-test` script for the NEGATIVE test.",
"check:facet-changes-map:built": "the SAME gate against the BUILT tree instead of the dev server. Measured 2026-09-24: the dev default is 1 PASS / 3 FAIL in four runs on a busy box (88 unbundled module requests vs the built leaf's 3), while the built tree passes deterministically. Use this when you need a trustworthy answer; use the dev default when checking uncommitted source.",

View file

@ -1132,7 +1132,9 @@ export default defineComponent({
let crossOrigin = false
try { crossOrigin = new URL(rootUrl, location.href).origin !== location.origin } catch { /* relative = same */ }
const candidates = crossOrigin ? [copy, root] : [root, copy]
const devFallback = '/public/doxa-maps/manifest.json'
// The tree's name comes from the build config (vite define), never a literal here: this was the
// one tree path compiled into any bundle, so a renamed tree would have needed a gallery edit.
const devFallback = '/public/' + __DOXA_TREE_NAME__ + '/manifest.json'
if (!candidates.some(c => c.url === devFallback)) candidates.push({ url: devFallback, root: dirOf(devFallback) })
const tried = []
const attempt = (i) => {

View file

@ -63,9 +63,7 @@ import { useGeocoderSearch } from '@map/composables/useGeocoderSearch.js'
// below.
// PERF: useMapPoster pulls in the tile-stitch + PDF composition pipeline
// (~117 KB). It's only needed when the user opens the poster dialog. Defer
// the import until that happens — see `openPoster()` below.
let _posterPromise = null
// (~117 KB). It is imported on the first "Export poster" click — see `openPoster()` below.
// PERF: useMapPacket builds a multi-page PDF (map page + one detail card per
// people group). It reuses useMapPoster's pipeline, so it's at least as heavy.
@ -148,17 +146,6 @@ function _syncScrollZoomToHost(m) {
m.scrollZoom.enable()
} catch (_) { /* an engine without scrollZoom must never break the panel */ }
}
function loadPoster() {
if (!_posterPromise) {
_posterPromise = import('@map/composables/poster/useMapPoster.js')
.then(m => m.useMapPoster({
sourceMap: () => map.value,
mapboxToken: mapboxToken.value,
styleUrl: () => map.value?.getStyle()?.sprite?.replace(/\/sprite.*$/, '') || profileConfig?.value?.style
}))
}
return _posterPromise
}
// Shared legend pipeline — lives in the library (@map/composables/legends/useLegendData.js).
import { useLegendData } from '@map/composables/legends/useLegendData.js'
@ -194,6 +181,7 @@ import ThemeToggleButton from '@map/components/map-controls/ThemeToggleButton.vu
import HamburgerButton from '@map/components/map-controls/HamburgerButton.vue'
import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import MapControlButton from '@map/components/map-controls/MapControlButton.vue'
// PERF: SideMenuDrawer is unused in the current research-map template (the
// ResearchMapSideMenu sibling renders the actual menu). Removed entirely from
@ -226,9 +214,7 @@ import { useWagfRegionsLegendData } from '@map/composables/legends/useWagfRegi
import { useLegendData as useFlatLegendData } from '@map/composables/legends/useLegendData.js'
import { RESEARCH_LEGEND_OPTIONS } from '@map/composables/legends/researchLegendOptions.js'
// PERF: PosterDialog only renders when the user opens the poster export
// flow. Async-import it so the dialog's deps don't load on every map boot.
const PosterDialog = retryableAsyncComponent(() => import('@map/components/poster/PosterDialog.vue'))
// ─── Shadow-DOM style injection ──────────────────────────────────────────────
useShadowStyles(`
@ -1310,11 +1296,58 @@ watch(() => mapStore.selectedRegion, (key) => {
// Instantiated only when the user opens the poster dialog. The composable +
// off-screen Mapbox instance + PDF pipeline are ~117 KB; keep them off the
// critical boot path.
const poster = ref(null)
// ★ ONE CLICK, ONE FILE (BUILD-SIMPLE-RESEARCH-EXPORT-ZERO-CLICK). The Driver: "there's an export
// button on the simple dashboard ... get it working". Measured 2026-09-25, leaf AND gallery: clicking
// "Export poster…" produced NOTHING — no dialog, no file in 60s, no error. Two bugs stacked: the
// template passed the poster dialog a `poster` prop it does not have and omitted its v-model and
// required getMap, so it mounted invisible; and the old loader called useMapPoster({ sourceMap, … })
// with an OBJECT where the shared composable takes a FUNCTION, so a render would have thrown anyway.
// Now the button does the export itself, through the SHARED poster path (useMapPoster → render →
// download), with a sensible print default: the current view on US Letter portrait at 200 DPI, the
// legend title and rows, and a source/date footer. No dialog, no second step.
const posterBusy = ref(false)
async function openPoster() {
if (!poster.value) poster.value = await loadPoster()
showPoster.value = true
if (posterBusy.value) return
posterBusy.value = true
try {
const [{ useMapPoster }, { defaultPosterSpec }] = await Promise.all([
import('@map/composables/poster/useMapPoster.js'),
import('@map/constants/posterDefaults.js'),
])
const exporter = useMapPoster(() => map.value)
const spec = defaultPosterSpec({ widthIn: 8.5, heightIn: 11, dpi: 200, orientation: 'portrait' })
// The poster is titled by the VIEW on screen ("Prayer", "Religion"…), in the reader's language.
// The legend's title bar is the fallback only: on a flat tab it is the generic word "Legend".
const title = tabLabel(activeTab.value) || legendTitle.value || 'Doxa Map'
// Direction follows the MAP's language, not the host page's: an English map on an Arabic page stays LTR.
spec.dir = ['ar', 'fa', 'he', 'ur'].includes(String(uiLocale.value || '').split('-')[0]) ? 'rtl' : 'ltr'
spec.slots.title = { ...spec.slots.title, text: title }
// The legend the reader sees: the flat tab's rows (and their sub-rows), else the aggregated ones.
const flatRows = (flatLegend.items.value || []).flatMap(it => [it, ...(it.children || [])])
const rows = (flatRows.length ? flatRows : (legend?.rows?.value || [])).map(r => ({ label: r.label, color: r.color }))
spec.slots.legend = { ...spec.slots.legend, rows, title: legendTitle.value }
// No scale bar or north arrow: the headless renderer's are placeholders (a fixed bar reading
// "scale"), which on a print would be a false statement — and a globe has no single scale.
spec.slots.scaleBar = false
spec.slots.north = false
// Credit the tiles actually drawn: OpenFreeMap on MapLibre, Mapbox otherwise.
const onMaplibre = !!(window.maplibregl && map.value instanceof window.maplibregl.Map)
spec.slots.footer = { ...spec.slots.footer, left: 'pray.doxa.life', right: new Date().toISOString().slice(0, 10),
attribution: onMaplibre ? '© OpenFreeMap © OpenMapTiles © OpenStreetMap' : '© Mapbox © OpenStreetMap' }
const blob = await exporter.render(spec, 'png')
// File name from the view's id, not its title: an Arabic or Chinese title has no ASCII letters
// to keep, and every reader's file would otherwise be the same "doxa-map-poster.png".
const slug = String(activeTab.value?.id || '').replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase()
_downloadBlob(blob, 'doxa-' + (slug || 'map') + '-poster.png')
} catch (err) {
console.error('[research-map] poster export failed:', err)
} finally {
posterBusy.value = false
}
}
// The toolbar's export label: the drawer's translated 'Export poster…' WITHOUT the ellipsis — an
// ellipsis promises a dialog, and this button hands over the file directly.
const exportPosterLabel = computed(() => String(t('controls.export_poster') || 'Export poster').replace(/\s*…\s*$/, ''))
// ─── Export menu router (SemanticTreeLegend @export) ─────────────────────────
// EXPERIMENTAL — dormant behind the shared kill-switch (SHOW_EXPORT_BUTTON).
@ -1326,7 +1359,7 @@ function onExport(kind) {
// ─── Packet export (multi-page PDF: map + one card per people group) ─────────
// PERF: useMapPacket reuses useMapPoster's render pipeline — heavy. Lazy-load
// once, mirroring loadPoster()/_posterPromise. Defensive throughout: never
// once, the same lazy-import pattern openPoster() uses. Defensive throughout: never
// throw uncaught into the UI; guard against double-run.
let _packetBusy = false
function _downloadBlob(blob, filename) {
@ -1377,7 +1410,6 @@ async function openPacket() {
// ─── Side-menu / dialog state ────────────────────────────────────────────────
const showSideMenu = ref(false)
const showPoster = ref(false)
const selectedFeature = computed(() => mapStore.selectedFeature)
// ─── Theme toggle (clone of doxa-simple-map.vue:368-395) ─────────────────────
@ -2388,7 +2420,6 @@ onBeforeUnmount(() => {
window.removeEventListener('legend:highlight', onLegendHighlight)
if (_containerObserver) { _containerObserver.disconnect(); _containerObserver = null }
if (_scrollZoomObserver) { _scrollZoomObserver.disconnect(); _scrollZoomObserver = null }
poster.value?.cleanup?.()
mapLayers.stopPrayerGlow()
clustering.cleanup?.()
selectedPin.cleanup?.()
@ -2459,6 +2490,20 @@ onBeforeUnmount(() => {
<LocationButton :map="map" :is-dark="isDark" />
<FullscreenButton :map-container="mapContainer" :is-dark="isDark" />
<ThemeToggleButton :is-dark="isDark" @toggle="handleToggleTheme" />
<!-- EXPORT, ONE CLICK, ALWAYS VISIBLE (BUILD-SIMPLE-RESEARCH-EXPORT-ZERO-CLICK). The only
export control used to live in the side drawer's footer, which is CLOSED by default
(translated off-screen), so a file took two clicks at best — and then nothing arrived.
This control sits in the toolbar every reader sees and exports the current view as a
print poster on the first click; busy while it renders, so a second click cannot
double-fire. -->
<MapControlButton :is-dark="isDark" :active="posterBusy" :disabled="posterBusy"
:title="exportPosterLabel" class="export-poster-btn" :aria-busy="posterBusy ? 'true' : 'false'"
@click="openPoster">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3v12M7 10l5 5 5-5M5 21h14" />
</svg>
</MapControlButton>
<!-- Share button hidden behind FEATURES.share (glitch — PR gate). All
ShareButton code kept warm; flip FEATURES.share=true to restore. -->
<ShareButton v-if="FEATURES.share" :is-dark="isDark" :embed-path="'doxa-research-map/research-map/index.html'" />
@ -2532,14 +2577,6 @@ onBeforeUnmount(() => {
@close="mapStore.selectFeature(null)"
/>
<!-- Poster export modal -->
<PosterDialog
v-if="showPoster"
:poster="poster"
:legend-rows="legend.rows"
:active-tab="activeTab"
@close="showPoster = false"
/>
</div>
</div>
</template>

View file

@ -0,0 +1,115 @@
#!/usr/bin/env node
/**
* check-export-one-click.mjs — the Simple Research "Export poster" button gives you a FILE on ONE
* click, with no menu, dialog or second step, in its own leaf AND inside the Research Gallery, in
* en AND ar.
*
* WHY (card BUILD-SIMPLE-RESEARCH-EXPORT-ZERO-CLICK; the Driver: "there's an export button on the
* simple dashboard ... get it working"). Measured 2026-09-25 before the fix: one click produced
* NOTHING — no dialog, no file within 60s, no error — in both hosts. A silent dead button reads as
* "the export is slow" to a reader, never as "it is broken", so no other gate could see it.
*
* WHAT IT ASSERTS, per host × language:
* 1. exactly ONE click on the ALWAYS-VISIBLE export button in the map toolbar (button.export-poster-btn).
* The drawer's "Export poster…" does not count: the drawer is CLOSED by default (translated off
* screen), so it costs an extra click to reach — measured 2026-09-25, and in Arabic the button
* could not be clicked at all. The toolbar button must be on screen with no preparation;
* 2. a download arrives within 120s, WITHOUT any further click;
* 3. the file is a real PNG (signature) of the print size (US Letter at 200 DPI = 1700x2200);
* 4. 0 page errors.
*
* Runs through internal/browser-slot.sh like every browser gate. Starts its own servers on
* OS-assigned ports and proves the server is THIS checkout's tree before measuring.
* --provider maplibre runs the same four on the keyless engine.
* EXPORT_SAVE_DIR=<dir> keeps each downloaded poster (<host>-<lang>-<file>) so a person can look at it.
* Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no playwright) · 75 NOT MEASURED (server identity)
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
if (!existsSync(resolve(ROOT, 'public/doxa-maps/research-map-plus-grid/research-map-plus-grid.js'))) {
console.log('SKIP check-export-one-click: no built research-map-plus-grid'); process.exit(2)
}
let chromium
try { ({ chromium } = await import('playwright')) } catch { console.log('SKIP check-export-one-click: playwright not installed'); process.exit(2) }
const freePort = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const PROVIDER = process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1] : '' // maplibre = the keyless engine
const TP = await freePort(), HP = await freePort()
const TREE = `http://127.0.0.1:${TP}/doxa-maps`
let lang = 'en'
const srv = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TP)], { cwd: ROOT, stdio: 'ignore' })
const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(`<!doctype html><html lang="${lang}"><body style="margin:0"><script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa tab="research-map-plus-grid"${PROVIDER ? ` provider="${PROVIDER}"` : ''} style="height:900px"></doxa-maps-spa></body></html>`) }).listen(HP, 'localhost')
const cleanup = () => { try { srv.kill() } catch {} try { host.close() } catch {} }
await new Promise(r => setTimeout(r, 800))
// Server identity by CONTENT: the served manifest must be this checkout's.
{
const disk = readFileSync(resolve(ROOT, 'public/doxa-maps/manifest.json'), 'utf8')
let served = ''
for (let i = 0; i < 20 && !served; i++) {
try { const r = await fetch(`${TREE}/manifest.json`); if (r.ok) served = await r.text() } catch { /* not up yet */ }
if (!served) await new Promise(r => setTimeout(r, 300))
}
if (served !== disk) { console.log(`NOT MEASURED: :${TP} is not serving this checkout's tree. Re-run.`); cleanup(); process.exit(75) }
}
const b = await chromium.launch({ args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] })
const fails = []
try {
for (lang of ['en', 'ar']) for (const where of ['leaf', 'gallery']) {
const url = where === 'leaf' ? `${TREE}/research-map-plus-grid/research-map-plus-grid/index.html?lang=${lang}${PROVIDER ? `&provider=${PROVIDER}` : ''}` : `http://localhost:${HP}/`
const ctx = await b.newContext({ acceptDownloads: true, viewport: { width: 1400, height: 900 } })
const p = await ctx.newPage(); const errs = []
p.on('pageerror', e => errs.push(String(e).slice(0, 160)))
// A failed export is CAUGHT and logged (console.error), never thrown: read the console, or a dead
// button reports "no file" with no reason.
const consoleErrs = []; p.on('console', m => { if (m.type() === 'error' || m.type() === 'warning') consoleErrs.push(m.text().slice(0, 200)) })
const downloads = []; p.on('download', d => downloads.push(d))
await p.goto(url); await p.waitForTimeout(12000)
const btn = p.locator('button.export-poster-btn').first()
const onScreen = await btn.evaluate(el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.left >= 0 && r.top >= 0 && r.right <= innerWidth && r.bottom <= innerHeight }).catch(() => false)
if (!onScreen) errs.push('the export button is not on screen without a prior click')
const label = ((await btn.getAttribute('title', { timeout: 20000 }).catch(() => '')) || '').trim()
let clicks = 0
// ONE PHYSICAL CLICK at the button's centre, as a reader makes it. Not locator.click(): that
// re-checks its hit target and RETRIES, and by the retry the button is already disabled (the
// export is running), so it reported "timeout" on exports whose file then arrived — measured
// 2026-09-25 in gallery-ar and leaf-en. What the click achieved is judged by the file below.
try {
const box = await btn.boundingBox({ timeout: 20000 })
if (!box) throw new Error('the export button has no box')
await p.mouse.click(box.x + box.width / 2, box.y + box.height / 2); clicks = 1
} catch (e) { errs.push('could not click: ' + String(e.message || e).split('\n')[0]) }
for (let i = 0; i < 120 && !downloads.length; i++) await p.waitForTimeout(1000)
let file = null, info = ''
if (downloads.length) {
file = downloads[0].suggestedFilename()
const path = await downloads[0].path()
const buf = readFileSync(path)
if (process.env.EXPORT_SAVE_DIR) writeFileSync(resolve(process.env.EXPORT_SAVE_DIR, `${where}-${lang}${PROVIDER ? '-' + PROVIDER : ''}-${file}`), buf) // to LOOK at the poster
const png = buf.length > 24 && buf.slice(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
const w = png ? buf.readUInt32BE(16) : 0, h = png ? buf.readUInt32BE(20) : 0
info = `${png ? 'PNG' : 'NOT-PNG'} ${w}x${h} ${Math.round(buf.length / 1024)}KB`
if (!png || w < 1600 || h < 2100) errs.push(`file is not the expected print PNG (${info})`)
}
if (!file) {
for (const c of consoleErrs.filter(c => /poster|useMapPoster/i.test(c)).slice(-2)) errs.push(c)
// SLOW or DEAD? A button still busy at the deadline is a render still running; an idle one is an
// export that ended without a file. The verdict is FAIL either way; the reason says which.
const busy = await btn.evaluate(el => el.disabled || el.getAttribute('aria-busy') === 'true').catch(() => null)
errs.push(busy ? 'still rendering at the deadline (button busy)' : 'the export ended with no file (button idle)')
}
const ok = clicks === 1 && !!file && !errs.length
console.log(`${ok ? 'PASS' : 'FAIL'} [${where} ${lang}${PROVIDER ? ' ' + PROVIDER : ''}] "${label}" · 1 click → ${file ? `${file} (${info})` : 'NO FILE within 120s'}${errs.length ? ' · ' + [...new Set(errs)].slice(0, 3).join(' | ') : ' · 0 errors'}`)
if (!ok) fails.push(`${where} ${lang}`)
await ctx.close()
}
} finally { await b.close(); cleanup() }
if (fails.length) { console.log(`\ncheck-export-one-click: FAIL (${fails.join(', ')})`); process.exit(1) }
console.log('\ncheck-export-one-click: PASS — one click on Export poster gives a print PNG, leaf and gallery, en and ar')

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-50ZKX8FcRxEmacDuUyngi9KOsmxU0bz7HzRaCS2zcYpPobksg3itqQwgUkxbyO0L" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-6Uk4vKHEvRs1FKY8Ld71vl3azsjXzyPgCXUrg8AeJWgT1FD8OUWKW7wu8PsJxniL" 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-50ZKX8FcRxEmacDuUyngi9KOsmxU0bz7HzRaCS2zcYpPobksg3itqQwgUkxbyO0L" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-6Uk4vKHEvRs1FKY8Ld71vl3azsjXzyPgCXUrg8AeJWgT1FD8OUWKW7wu8PsJxniL" 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-50ZKX8FcRxEmacDuUyngi9KOsmxU0bz7HzRaCS2zcYpPobksg3itqQwgUkxbyO0L" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-6Uk4vKHEvRs1FKY8Ld71vl3azsjXzyPgCXUrg8AeJWgT1FD8OUWKW7wu8PsJxniL" 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

@ -158,7 +158,7 @@
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
"output": "doxa-maps/country-posters/country-posters.js",
"integrity": "sha384-50ZKX8FcRxEmacDuUyngi9KOsmxU0bz7HzRaCS2zcYpPobksg3itqQwgUkxbyO0L",
"integrity": "sha384-6Uk4vKHEvRs1FKY8Ld71vl3azsjXzyPgCXUrg8AeJWgT1FD8OUWKW7wu8PsJxniL",
"elements": [
"country-posters-map"
],
@ -178,7 +178,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": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-50ZKX8FcRxEmacDuUyngi9KOsmxU0bz7HzRaCS2zcYpPobksg3itqQwgUkxbyO0L\" 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": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-6Uk4vKHEvRs1FKY8Ld71vl3azsjXzyPgCXUrg8AeJWgT1FD8OUWKW7wu8PsJxniL\" 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>"
}
},
{
@ -192,7 +192,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-50ZKX8FcRxEmacDuUyngi9KOsmxU0bz7HzRaCS2zcYpPobksg3itqQwgUkxbyO0L\" 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-6Uk4vKHEvRs1FKY8Ld71vl3azsjXzyPgCXUrg8AeJWgT1FD8OUWKW7wu8PsJxniL\" 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>"
}
},
{
@ -206,7 +206,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": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-50ZKX8FcRxEmacDuUyngi9KOsmxU0bz7HzRaCS2zcYpPobksg3itqQwgUkxbyO0L\" 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": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-6Uk4vKHEvRs1FKY8Ld71vl3azsjXzyPgCXUrg8AeJWgT1FD8OUWKW7wu8PsJxniL\" 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>"
}
}
]
@ -354,7 +354,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "doxa-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-M9zI75lchJ/lMfPAvDUVUed7HZXoOd4RSAq0rF16ohbSfdwG/g0aqK/1QDsIgnfc",
"integrity": "sha384-smxkLip3BUQ3NvfPk15gwPfYepcWieirbddwayS0WNI7uVaULvJqqHrlcqhqW++j",
"elements": [
"doxa-research-map"
],
@ -370,7 +370,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-M9zI75lchJ/lMfPAvDUVUed7HZXoOd4RSAq0rF16ohbSfdwG/g0aqK/1QDsIgnfc\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-smxkLip3BUQ3NvfPk15gwPfYepcWieirbddwayS0WNI7uVaULvJqqHrlcqhqW++j\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
},
"instances": [
{

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 <doxa-research-map> custom element. -->
<script src="../doxa-research-map.js" integrity="sha384-M9zI75lchJ/lMfPAvDUVUed7HZXoOd4RSAq0rF16ohbSfdwG/g0aqK/1QDsIgnfc" crossorigin="anonymous"></script>
<script src="../doxa-research-map.js" integrity="sha384-smxkLip3BUQ3NvfPk15gwPfYepcWieirbddwayS0WNI7uVaULvJqqHrlcqhqW++j" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -27,7 +27,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../../doxa-research-map.js" integrity="sha384-M9zI75lchJ/lMfPAvDUVUed7HZXoOd4RSAq0rF16ohbSfdwG/g0aqK/1QDsIgnfc" crossorigin="anonymous"></script>
<script src="../../doxa-research-map.js" integrity="sha384-smxkLip3BUQ3NvfPk15gwPfYepcWieirbddwayS0WNI7uVaULvJqqHrlcqhqW++j" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -158,7 +158,7 @@
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
"output": "doxa-maps/country-posters/country-posters.js",
"integrity": "sha384-50ZKX8FcRxEmacDuUyngi9KOsmxU0bz7HzRaCS2zcYpPobksg3itqQwgUkxbyO0L",
"integrity": "sha384-6Uk4vKHEvRs1FKY8Ld71vl3azsjXzyPgCXUrg8AeJWgT1FD8OUWKW7wu8PsJxniL",
"elements": [
"country-posters-map"
],
@ -178,7 +178,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": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-50ZKX8FcRxEmacDuUyngi9KOsmxU0bz7HzRaCS2zcYpPobksg3itqQwgUkxbyO0L\" 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": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-6Uk4vKHEvRs1FKY8Ld71vl3azsjXzyPgCXUrg8AeJWgT1FD8OUWKW7wu8PsJxniL\" 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>"
}
},
{
@ -192,7 +192,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-50ZKX8FcRxEmacDuUyngi9KOsmxU0bz7HzRaCS2zcYpPobksg3itqQwgUkxbyO0L\" 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-6Uk4vKHEvRs1FKY8Ld71vl3azsjXzyPgCXUrg8AeJWgT1FD8OUWKW7wu8PsJxniL\" 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>"
}
},
{
@ -206,7 +206,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": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-50ZKX8FcRxEmacDuUyngi9KOsmxU0bz7HzRaCS2zcYpPobksg3itqQwgUkxbyO0L\" 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": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-6Uk4vKHEvRs1FKY8Ld71vl3azsjXzyPgCXUrg8AeJWgT1FD8OUWKW7wu8PsJxniL\" 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>"
}
}
]
@ -354,7 +354,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "doxa-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-M9zI75lchJ/lMfPAvDUVUed7HZXoOd4RSAq0rF16ohbSfdwG/g0aqK/1QDsIgnfc",
"integrity": "sha384-smxkLip3BUQ3NvfPk15gwPfYepcWieirbddwayS0WNI7uVaULvJqqHrlcqhqW++j",
"elements": [
"doxa-research-map"
],
@ -370,7 +370,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-M9zI75lchJ/lMfPAvDUVUed7HZXoOd4RSAq0rF16ohbSfdwG/g0aqK/1QDsIgnfc\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-smxkLip3BUQ3NvfPk15gwPfYepcWieirbddwayS0WNI7uVaULvJqqHrlcqhqW++j\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
},
"instances": [
{

View file

@ -94,7 +94,12 @@ export default defineConfig(({ command, mode }) => {
// the maintainer's internal platform and must NOT exist in public
// artifacts. Default FALSE strips it from every bundle (dead-branch +
// treeshake); an explicitly-flagged platform build sets DOXA_QA=1.
define: { __DOXA_QA__: JSON.stringify(process.env.DOXA_QA === '1') },
define: {
__DOXA_QA__: JSON.stringify(process.env.DOXA_QA === '1'),
// The output tree's name (vite.doxa-maps-build-config.json `name`, the same value OUT_ROOT uses),
// for the few runtime paths that must name the tree — so renaming the tree needs no source edit.
__DOXA_TREE_NAME__: JSON.stringify(MFE_NAME),
},
// Build only: shadow-DOM CSS is injected from useShadowStyles(`…`) literals that
// ship verbatim, so their block comments are stripped before the Vue compiler
// runs (enforce: 'pre'). Dev keeps the comments.