Merge remote-tracking branch 'origin/development' into wall-of-people-groups-plugin/work

# Conflicts:
#	package.json
This commit is contained in:
Doxa Maps 2026-09-30 21:47:28 +00:00
commit f8de00edf7
41 changed files with 2364 additions and 249 deletions

View file

@ -0,0 +1,154 @@
#!/usr/bin/env node
// check-batch-selection-export — UPG-100's batch selection EXPORTS its selection (restored 2026-09-30).
//
// WHY IT MATTERS: when the people-group data changes, partners redraw the zones on the map, Copy or Export the
// selection, and hand it to an agent that regroups the zones (data/apply-grouping.mjs) and regenerates the
// posters. The copy/export path was removed once and the maps stopped being reproducible, so this pins it.
//
// Headless, no browser, no network. Asserts:
// A. Copy's FIRST line is exactly what data/apply-grouping.mjs parses: fed through its own parsePayload, the
// text returns the selected territory names, the UUPG total and the colour. The CSV after it must not
// change that.
// B. The CSV: the header is the declared columns; a cell with a comma, a quote or a line break survives a
// real CSV parse; a leading = + - @ is neutralised (a spreadsheet never runs a name as a formula); one row
// per people group, carrying the territories that contain it and the picked colour.
// C. The HUD wires it: the colour swatches + hex input, Copy and Export CSV buttons with their handlers, and
// batch mode prefetching the records so a tap copies inside its user activation.
// D. The nine new strings exist in all 11 locales, non-empty, and translated (not English) outside en.
import { readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const P = (...a) => join(REPO, ...a)
const fails = []
const notes = []
const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
let X, parsePayload, runApply, formatClipboard
try {
X = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/selection-export.js')).href)
;({ parsePayload, runApply } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/apply-grouping.mjs')).href))
;({ formatClipboard } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/territory-counts.js')).href))
} catch (e) {
console.error(`BATCH-SELECTION-EXPORT: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
process.exit(75)
}
// ── A. the clipboard's first line round-trips through apply-grouping ─────────────────────────────
const selection = [
{ key: 'adm1:Mahārāshtra', name: 'Mahārāshtra', count: 54, kind: 'adm1' },
{ key: 'adm1:Goa', name: 'Goa', count: 5, kind: 'adm1' },
{ key: 'adm0:NPL', name: 'Nepal', count: 34, kind: 'adm0' },
]
const records = [
{ slug: 'agaria', name: 'Agaria', country_code: 'IND', population: 12000 },
{ slug: 'q-comma', name: 'Bhil, Tadvi "Hill"', country_code: 'IND', population: 3100 },
{ slug: 'q-formula', name: '=HYPERLINK("x")', country_code: 'NPL', population: 450 },
{ slug: 'q-newline', name: 'Line one\nLine two', country_code: 'NPL', population: 7 },
{ slug: 'q-tab', name: '\t=cmd', country_code: 'NPL', population: 1 },
]
const from = new Map([['agaria', ['Mahārāshtra', 'Goa']], ['q-comma', ['Goa']], ['q-formula', ['Nepal']], ['q-newline', ['Nepal']], ['q-tab', ['Nepal']]])
const color = '#e69f00'
const csv = X.selectionCsv(records, from, { color })
const text = X.selectionClipboard(selection, { color }, csv)
let parsed = null
try { parsed = parsePayload(text) } catch (e) { fails.push(`apply-grouping's parsePayload REFUSED the Copy text: ${e.message}`) }
if (parsed) {
const machine = ['Mahārāshtra', 'Goa', 'NPL']
ok(JSON.stringify(parsed.names) === JSON.stringify(machine),
`apply-grouping reads the names as ${JSON.stringify(parsed.names)}, expected ${JSON.stringify(machine)}`,
'Copy\'s first line parses in apply-grouping: the 3 territories in machine names (a country as its ISO3), in order')
ok(parsed.total === 93, `apply-grouping reads the total as ${parsed.total}, expected 93`, 'the UUPG total (93) and the colour survive the CSV after them')
ok(parsed.hex === color, `apply-grouping reads the colour as ${parsed.hex}, expected ${color}`)
}
ok(text.split('\n')[0] === 'Mahārāshtra, Goa, NPL — 93 UUPG · #e69f00', `Copy's first line is "${text.split('\n')[0]}"`)
ok(text.split('\n')[1] === 'Mahārāshtra 54, Goa 5, Nepal 34', `Copy's second line (the reader's names) is "${text.split('\n')[1]}"`)
ok(text.includes('\n\n' + csv), 'Copy does not carry the CSV after a blank line')
ok(X.selectionClipboard(selection, {}, '').split('\n').length === 2, 'Copy without people groups is not exactly the two territory lines')
// ── A2. apply-grouping RESOLVES line 1 in ANY UI language ───────────────────────────────────────────────
// parsePayload only splits the names; resolution happens later (upsertPayloadZone), so this runs the tool's own
// dry run. In Arabic the HUD shows "نيبال", and a line 1 carrying it was rejected ("unresolved territory name(s)").
// The NEGATIVE CONTROL proves this check can fail: the same selection formatted with the reader's names must be
// refused by the same dry run.
const arabic = [
{ key: 'adm0:NPL', name: 'نيبال', count: 34, kind: 'adm0' },
{ key: 'adm0:BTN', name: 'بوتان', count: 13, kind: 'adm0' },
]
const dry = (payloadText) => { try { const r = runApply({ payloadText, dry: true, quiet: true }); return r && r.ok !== false ? 'ok' : 'not ok' } catch (e) { return 'threw: ' + String(e.message || e).slice(0, 120) } }
const arText = X.selectionClipboard(arabic, { color: '#0072b2' })
ok(dry(arText) === 'ok', `apply-grouping's dry run REFUSES the Arabic Copy: ${dry(arText)} (line 1 "${arText.split('\n')[0]}")`,
'an ARABIC selection\'s Copy resolves in apply-grouping\'s own dry run (line 1 "NPL, BTN — 47 UUPG · #0072b2")')
ok(arText.split('\n')[1] === 'نيبال 34, بوتان 13', `the Arabic Copy's second line is "${arText.split('\n')[1]}", expected the reader's names`)
ok(dry(formatClipboard(arabic, { color: '#0072b2' })) !== 'ok', 'NEGATIVE CONTROL: a line 1 in the reader\'s Arabic names should be refused by the dry run, and was not: this check cannot fail',
'negative control: the same selection in the reader\'s names IS refused (the check can fail)')
const indiaText = X.selectionClipboard([{ key: 'adm1:Mahārāshtra', name: 'Mahārāshtra', count: 54, kind: 'adm1' }, { key: 'adm1:Goa', name: 'Goa', count: 5, kind: 'adm1' }], {})
ok(dry(indiaText) === 'ok', `apply-grouping's dry run refuses an India state selection: ${dry(indiaText)}`, 'an India state selection resolves too (shape names are canonical)')
// ── B. the CSV ───────────────────────────────────────────────────────────────────────────────────
function parseCsv(s) { // RFC 4180: quoted fields, doubled quotes, CRLF records
const rows = []; let row = [], cell = '', q = false
for (let i = 0; i < s.length; i++) {
const c = s[i]
if (q) { if (c === '"') { if (s[i + 1] === '"') { cell += '"'; i++ } else q = false } else cell += c }
else if (c === '"') q = true
else if (c === ',') { row.push(cell); cell = '' }
else if (c === '\r' && s[i + 1] === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; i++ }
else cell += c
}
row.push(cell); rows.push(row)
return rows
}
const rows = parseCsv(csv)
ok(JSON.stringify(rows[0]) === JSON.stringify(X.SELECTION_CSV_COLUMNS), `the CSV header is ${JSON.stringify(rows[0])}`, `the CSV header: ${X.SELECTION_CSV_COLUMNS.join(',')}`)
ok(rows.length === 1 + records.length, `the CSV has ${rows.length - 1} data rows for ${records.length} people groups`)
const col = (r, k) => r[X.SELECTION_CSV_COLUMNS.indexOf(k)]
ok(col(rows[2], 'name') === 'Bhil, Tadvi "Hill"', `a name with a comma and quotes reads back as ${JSON.stringify(col(rows[2], 'name'))}`)
ok(col(rows[4], 'name') === 'Line one\nLine two', `a name with a line break reads back as ${JSON.stringify(col(rows[4], 'name'))}`,
'commas, quotes and line breaks survive a real CSV parse')
ok(col(rows[3], 'name') === '\'=HYPERLINK("x")', `a leading = reads back as ${JSON.stringify(col(rows[3], 'name'))}: a spreadsheet would run it`,
'a leading = + - @ is neutralised')
ok(col(rows[5], 'name') === "'\t=cmd", `a leading TAB reads back as ${JSON.stringify(col(rows[5], 'name'))}: OWASP lists it as a formula trigger`,
'a leading TAB or CR is neutralised too (OWASP)')
ok(col(rows[1], 'territories') === 'Mahārāshtra; Goa' && col(rows[1], 'color') === color && col(rows[1], 'slug') === 'agaria',
`row 1 is ${JSON.stringify(rows[1])}`, 'each row carries its slug, the territories that contain it, and the colour')
// ── C. the HUD wires it ──────────────────────────────────────────────────────────────────────────
const vue = readFileSync(P('plugin-designs/my-upg-100-list/upg-100-list.vue'), 'utf8')
const wired = {
'the colour swatches': /class="upg-swatch"[\s\S]{0,400}@click="setSelectionColor\(c\)"/.test(vue),
'the custom hex input': /class="upg-hex"[\s\S]{0,200}@change="onHexInput"/.test(vue),
'the Copy button': /@click="copySelection">\{\{ t\('upgMap\.copySelection'\) \}\}/.test(vue),
'the Export CSV button': /@click="exportSelectionCsv">/.test(vue) && vue.includes("t('upgMap.exportCsv')"),
'the prefetch on batch mode': /if \(next\) selectionRecordsLoad\(\)/.test(vue),
'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /selectionPeople\(\)\.then/.test(vue),
}
const missingWires = Object.entries(wired).filter(([, v]) => !v).map(([k]) => k)
ok(!missingWires.length, `the HUD is missing: ${missingWires.join(', ')}`, 'the HUD wires the swatches, hex input, Copy, Export CSV, and the prefetch')
// ── D. the strings, ×11 ──────────────────────────────────────────────────────────────────────────
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
const KEYS = {
upgMap: ['copySelection', 'exportCsv', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8'))
const bad = []
for (const [f, keys] of Object.entries(KEYS)) {
const en = cat('en', f)
for (const l of LANGS) {
const c = cat(l, f)
for (const k of keys) {
if (typeof c[k] !== 'string' || !c[k].trim()) bad.push(`${l} ${f}.${k} missing`)
else if (l !== 'en' && c[k] === en[k]) bad.push(`${l} ${f}.${k} is English`)
}
}
}
ok(!bad.length, `strings: ${bad.slice(0, 8).join('; ')}${bad.length > 8 ? ` (+${bad.length - 8})` : ''}`, `the ${KEYS.upgMap.length} new strings in all ${LANGS.length} locales, translated`)
for (const n of notes) console.log(n)
if (fails.length) {
console.error('BATCH-SELECTION-EXPORT: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log('BATCH-SELECTION-EXPORT: PASS — Copy parses in apply-grouping, the CSV is well-formed and safe, the HUD wires both, and the strings are in all 11 locales.')

View file

@ -17,7 +17,7 @@
* were a terminal's rendering, not the bytes.
* - "quoting is broken" — FALSE. esc() is genuinely RFC-4180: it quotes when , " CR or LF appear and
* doubles internal quotes. Embedded commas, quotes AND newlines survive a strict parser round trip.
* - "CSV injection" — already handled; a leading = + - @ is prefixed with a single quote.
* - "CSV injection" — handled; a leading = + - @ TAB or CR is prefixed with a single quote (asserted in 5).
* Only the POSITION was wrong. Chasing the other three would have produced churn and no fix.
*
* ★ WHY NO EXISTING GATE SAW IT. 46 gate-ish scripts exist and not one parses the export. The bug is
@ -89,8 +89,10 @@ const rows = [
{ slug: 'kluet', name: 'Kluet', religion: 'Islam - Sunni',
imb_people_description: 'Line one\nLine two, with a comma' },
{ slug: 'inj', name: '=SUM(A1)', religion: 'Islam - Sunni', imb_people_description: 'injection probe' },
{ slug: 'inj-tab', name: '\t=cmd', religion: 'Islam - Sunni', imb_people_description: 'injection probe, a leading TAB' },
{ slug: 'inj-cr', name: '\r=cmd', religion: 'Islam - Sunni', imb_people_description: 'injection probe, a leading CR' },
]
const provenance = '# Research Dashboard export — scope: filter result; filters: religion=Islam - Sunni; rows: 3'
const provenance = '# Research Dashboard export — scope: filter result; filters: religion=Islam - Sunni; rows: 5'
const body = [cols.map(esc).join(','), ...rows.map((r) => cols.map((c) => esc(r[c])).join(','))]
const csv = (provenanceFirst ? [provenance, ...body] : [...body, provenance]).join('\r\n')
@ -164,6 +166,18 @@ if (!(last.length === 1 && String(last[0]).startsWith('#'))) {
' between a dataset and an anecdote. Keep it — just keep it last.')
}
// 5 — every formula trigger comes back NEUTRALISED (a leading single quote), so a spreadsheet shows it as text.
// The probes cover = and OWASP's other two leading triggers, TAB and CR, which a spreadsheet can strip before
// reading the = behind them. Checked by SLUG, so the rows above may be reordered.
const bySlug = new Map(dataRows.map((r) => [r[0], r]))
for (const [slug, raw] of [['inj', '=SUM(A1)'], ['inj-tab', '\t=cmd'], ['inj-cr', '\r=cmd']]) {
const got = bySlug.get(slug) && bySlug.get(slug)[1]
if (got !== "'" + raw) {
fail(`the formula probe ${JSON.stringify(raw)} came back as ${JSON.stringify(got)}, not neutralised with a leading quote.`,
' esc() must prefix a single quote to a value starting with = + - @ TAB or CR (CSV injection).')
}
}
// Reaching here under --self-test means the gate WAVED THROUGH a known-broken export.
if (SELF_TEST) {
console.error('CSV-SHAPE SELF-TEST: FAIL — the gate PASSED a deliberately broken export.')

View file

@ -53,13 +53,21 @@ const fail = (m) => { console.error('TAB-RADIUS: FAIL — ' + m); process.exit(1
// catches 999px, 9999px and the occasional 50vh.
const PILL_MIN = 100
// Does this line open a rule whose selector list names `cls` as a WHOLE selector? `.vtl-collapse, .vtl-home {`
// counts (the home link joined Collapse's rule so the two share one box); `.vtl-collapse:hover`,
// `.vtl-collapse-tx` and `.vtl.rail-collapsed .vtl-collapse` do not.
function opensRuleFor(ln, cls) {
const m = ln.match(/^\s*([^{}]+)\{/)
return !!m && m[1].split(',').map((x) => x.trim()).includes(cls)
}
function auditLines(lines) {
const problems = []
// ── the reference: .vtl-collapse's literal radius, read from the file, never assumed ──────────
const collapse = []
lines.forEach((ln, i) => {
if (!/^\s*\.vtl-collapse\s*\{/.test(ln)) return
if (!opensRuleFor(ln, '.vtl-collapse')) return
const m = ln.match(/border-radius:\s*([^;]+);/)
if (m) collapse.push({ line: i + 1, value: m[1].trim() })
})
@ -151,6 +159,9 @@ if (process.argv.includes('--self-test')) {
'cursor:pointer; border-radius:999px; font:inherit;')],
['literal-instead-of-token', (s) => s.replace('cursor:pointer; border-radius:var(--vtl-radius,8px); font:inherit;',
'cursor:pointer; border-radius:8px; font:inherit;')],
// the reference must be read from Collapse's rule even inside a selector list: change ONLY that rule's
// radius and the (unchanged) tabs no longer agree with it.
['collapse-radius-moved', (s) => s.split('\n').map((l) => (opensRuleFor(l, '.vtl-collapse') ? l.replace(/border-radius:\s*8px;/, 'border-radius:12px;') : l)).join('\n')],
]
let bad = 0
for (const [name, poison] of cases) {
@ -161,7 +172,7 @@ if (process.argv.includes('--self-test')) {
if (!caught) bad++
}
if (bad) { console.error(`TAB-RADIUS SELF-TEST: FAIL — ${bad} case(s) not caught.`); process.exit(1) }
console.log('TAB-RADIUS SELF-TEST: PASS — both regressions caught; tree untouched (poisoned in memory).')
console.log('TAB-RADIUS SELF-TEST: PASS — every regression caught; tree untouched (poisoned in memory).')
process.exit(0)
}

View file

@ -199,10 +199,12 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
for (const k of Object.keys(r)) o[k] = cell(r[k])
return o
}
// Quote if the field has , " CR LF; double internal quotes; neutralize leading = + - @ (CSV injection).
// Quote if the field has , " CR LF; double internal quotes; neutralize a leading = + - @ TAB or CR (CSV
// injection: OWASP's full list of formula triggers, since a spreadsheet can strip a leading TAB/CR and then
// read the = that follows as a formula).
function esc(v) {
let s = v === null || v === undefined ? '' : String(v)
if (/^[=+\-@]/.test(s)) s = "'" + s
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s
if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"'
return s
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "الملصقات متاحة لكل منطقة في مفتاح الخريطة — استخدم «قائمة الصلاة» لتحديد على الخريطة",
"mapDataFailed": "تعذّر تحميل بيانات الخريطة",
"infoLead": "قائمة UPG-100 على خريطة واحدة: الشعوب غير المُبشَّرة، مجمّعة في مناطق يمكنك أن تتبنّاها بالصلاة.",
"infoTakeaway": "لكل منطقة: ملصق للحائط، وحزمة صلاة للتوزيع، ومجموعة من بطاقات الصلاة. المثال أدناه هو المنطقة التي تضم أكبر عدد من الناس في شعوب لا يعمل أحد بعد على غرس كنائس بينها."
"infoTakeaway": "لكل منطقة: ملصق للحائط، وحزمة صلاة للتوزيع، ومجموعة من بطاقات الصلاة. المثال أدناه هو المنطقة التي تضم أكبر عدد من الناس في شعوب لا يعمل أحد بعد على غرس كنائس بينها.",
"selectionColourGroup": "لون التحديد",
"selectionColour": "تلوين التحديد {hex}",
"customColour": "لون مخصص (#rrggbb)",
"copiedSelection": "تم النسخ — الشعوب: {count}",
"copiedTerritoriesOnly": "تم نسخ الأقاليم فقط — {reason}",
"csvSaved": "تم حفظ CSV — الشعوب: {count}",
"csvFailed": "فشل تصدير CSV: {reason}",
"copySelection": "نسخ",
"exportCsv": "تصدير CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Plakate gibt es pro Gebiet in der Legende – nutzen Sie Gebetsliste für eine Auswahl auf der Karte",
"mapDataFailed": "Die Kartendaten konnten nicht geladen werden",
"infoLead": "Die UPG-100-Liste auf einer Karte: unerreichte Volksgruppen, zusammengefasst in Zonen, die Sie im Gebet übernehmen können.",
"infoTakeaway": "für jede Zone ein Wandposter, ein Gebetsheft zum Weitergeben und ein Satz Gebetskarten. Das Beispiel unten ist die Zone mit den meisten Menschen in Volksgruppen, unter denen noch niemand Gemeinden gründet."
"infoTakeaway": "für jede Zone ein Wandposter, ein Gebetsheft zum Weitergeben und ein Satz Gebetskarten. Das Beispiel unten ist die Zone mit den meisten Menschen in Volksgruppen, unter denen noch niemand Gemeinden gründet.",
"selectionColourGroup": "Farbe der Auswahl",
"selectionColour": "Auswahl einfärben: {hex}",
"customColour": "Eigene Farbe (#rrggbb)",
"copiedSelection": "Kopiert — Volksgruppen: {count}",
"copiedTerritoriesOnly": "Nur die Gebiete kopiert — {reason}",
"csvSaved": "CSV gespeichert — Volksgruppen: {count}",
"csvFailed": "CSV-Export fehlgeschlagen: {reason}",
"copySelection": "Kopieren",
"exportCsv": "CSV exportieren"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Posters exist per territory on the legend — use Prayer List for a map selection",
"mapDataFailed": "The map data could not be loaded",
"infoLead": "The UPG-100 list on one map: unreached people groups, gathered into zones you can adopt in prayer.",
"infoTakeaway": "for any zone, a wall poster, a prayer packet to hand out and a deck of prayer cards. The example below is the zone with the most people in groups where no one is planting churches yet."
"infoTakeaway": "for any zone, a wall poster, a prayer packet to hand out and a deck of prayer cards. The example below is the zone with the most people in groups where no one is planting churches yet.",
"selectionColourGroup": "Selection colour",
"selectionColour": "Colour the selection {hex}",
"customColour": "Custom colour (#rrggbb)",
"copiedSelection": "Copied — people groups: {count}",
"copiedTerritoriesOnly": "Copied the territories only — {reason}",
"csvSaved": "CSV saved — people groups: {count}",
"csvFailed": "CSV failed: {reason}",
"copySelection": "Copy",
"exportCsv": "Export CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Hay carteles por territorio en la leyenda; usa Lista de oración para una selección en el mapa",
"mapDataFailed": "No se pudieron cargar los datos del mapa",
"infoLead": "La lista UPG-100 en un solo mapa: pueblos no alcanzados, agrupados en zonas que usted puede adoptar en oración.",
"infoTakeaway": "para cada zona, un póster para la pared, un paquete de oración para repartir y un mazo de tarjetas de oración. El ejemplo de abajo es la zona con más personas en pueblos donde todavía nadie está plantando iglesias."
"infoTakeaway": "para cada zona, un póster para la pared, un paquete de oración para repartir y un mazo de tarjetas de oración. El ejemplo de abajo es la zona con más personas en pueblos donde todavía nadie está plantando iglesias.",
"selectionColourGroup": "Color de la selección",
"selectionColour": "Colorear la selección {hex}",
"customColour": "Color personalizado (#rrggbb)",
"copiedSelection": "Copiado — pueblos: {count}",
"copiedTerritoriesOnly": "Solo se copiaron los territorios — {reason}",
"csvSaved": "CSV guardado — pueblos: {count}",
"csvFailed": "Error al exportar el CSV: {reason}",
"copySelection": "Copiar",
"exportCsv": "Exportar CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Les affiches existent par territoire dans la légende — utilisez Liste de prière pour une sélection sur la carte",
"mapDataFailed": "Les données de la carte n’ont pas pu être chargées",
"infoLead": "La liste UPG-100 sur une seule carte : les peuples non atteints, regroupés en zones que vous pouvez adopter dans la prière.",
"infoTakeaway": "pour chaque zone, une affiche murale, un livret de prière à distribuer et un jeu de cartes de prière. L'exemple ci-dessous est la zone qui compte le plus de personnes dans des peuples où personne n'implante encore d'Églises."
"infoTakeaway": "pour chaque zone, une affiche murale, un livret de prière à distribuer et un jeu de cartes de prière. L'exemple ci-dessous est la zone qui compte le plus de personnes dans des peuples où personne n'implante encore d'Églises.",
"selectionColourGroup": "Couleur de la sélection",
"selectionColour": "Colorer la sélection {hex}",
"customColour": "Couleur personnalisée (#rrggbb)",
"copiedSelection": "Copié — peuples : {count}",
"copiedTerritoriesOnly": "Seuls les territoires ont été copiés — {reason}",
"csvSaved": "CSV enregistré — peuples : {count}",
"csvFailed": "Échec de l’export CSV : {reason}",
"copySelection": "Copier",
"exportCsv": "Exporter en CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "पोस्टर लेजेंड में हर क्षेत्र के लिए हैं — मानचित्र पर चयन के लिए प्रार्थना सूची का उपयोग करें",
"mapDataFailed": "मानचित्र का डेटा लोड नहीं हो सका",
"infoLead": "UPG-100 सूची एक ही नक्शे पर: अपहुँचे जन-समूह, क्षेत्रों में बाँटे गए, जिन्हें आप प्रार्थना में अपना सकते हैं।",
"infoTakeaway": "हर क्षेत्र के लिए दीवार का एक पोस्टर, बाँटने के लिए एक प्रार्थना पुस्तिका और प्रार्थना कार्डों का एक सेट। नीचे का उदाहरण वह क्षेत्र है जहाँ ऐसे जन-समूहों में सबसे अधिक लोग हैं जिनके बीच अभी कोई कलीसिया स्थापित नहीं कर रहा है।"
"infoTakeaway": "हर क्षेत्र के लिए दीवार का एक पोस्टर, बाँटने के लिए एक प्रार्थना पुस्तिका और प्रार्थना कार्डों का एक सेट। नीचे का उदाहरण वह क्षेत्र है जहाँ ऐसे जन-समूहों में सबसे अधिक लोग हैं जिनके बीच अभी कोई कलीसिया स्थापित नहीं कर रहा है।",
"selectionColourGroup": "चयन का रंग",
"selectionColour": "चयन को रंगें: {hex}",
"customColour": "अपना रंग (#rrggbb)",
"copiedSelection": "कॉपी हुआ — जनसमूह: {count}",
"copiedTerritoriesOnly": "केवल क्षेत्र कॉपी हुए — {reason}",
"csvSaved": "CSV सहेजा गया — जनसमूह: {count}",
"csvFailed": "CSV निर्यात विफल: {reason}",
"copySelection": "कॉपी करें",
"exportCsv": "CSV निर्यात करें"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "I manifesti esistono per territorio nella legenda: usa Lista di preghiera per una selezione sulla mappa",
"mapDataFailed": "Impossibile caricare i dati della mappa",
"infoLead": "La lista UPG-100 su un'unica mappa: popoli non raggiunti, raggruppati in zone che puoi adottare nella preghiera.",
"infoTakeaway": "per ogni zona, un poster da parete, un libretto di preghiera da distribuire e un mazzo di carte di preghiera. L'esempio qui sotto è la zona con più persone in popoli dove nessuno sta ancora fondando chiese."
"infoTakeaway": "per ogni zona, un poster da parete, un libretto di preghiera da distribuire e un mazzo di carte di preghiera. L'esempio qui sotto è la zona con più persone in popoli dove nessuno sta ancora fondando chiese.",
"selectionColourGroup": "Colore della selezione",
"selectionColour": "Colora la selezione {hex}",
"customColour": "Colore personalizzato (#rrggbb)",
"copiedSelection": "Copiato — popoli: {count}",
"copiedTerritoriesOnly": "Copiati solo i territori — {reason}",
"csvSaved": "CSV salvato — popoli: {count}",
"csvFailed": "Esportazione CSV non riuscita: {reason}",
"copySelection": "Copia",
"exportCsv": "Esporta CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Há cartazes por território na legenda — use Lista de oração para uma seleção no mapa",
"mapDataFailed": "Não foi possível carregar os dados do mapa",
"infoLead": "A lista UPG-100 em um só mapa: povos não alcançados, agrupados em zonas que você pode adotar em oração.",
"infoTakeaway": "para cada zona, um pôster de parede, um pacote de oração para distribuir e um baralho de cartões de oração. O exemplo abaixo é a zona com mais pessoas em povos onde ninguém ainda está plantando igrejas."
"infoTakeaway": "para cada zona, um pôster de parede, um pacote de oração para distribuir e um baralho de cartões de oração. O exemplo abaixo é a zona com mais pessoas em povos onde ninguém ainda está plantando igrejas.",
"selectionColourGroup": "Cor da seleção",
"selectionColour": "Colorir a seleção {hex}",
"customColour": "Cor personalizada (#rrggbb)",
"copiedSelection": "Copiado — povos: {count}",
"copiedTerritoriesOnly": "Apenas os territórios foram copiados — {reason}",
"csvSaved": "CSV salvo — povos: {count}",
"csvFailed": "Falha ao exportar o CSV: {reason}",
"copySelection": "Copiar",
"exportCsv": "Exportar CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Afișele există pe teritoriu în legendă — folosiți Listă de rugăciune pentru o selecție pe hartă",
"mapDataFailed": "Datele hărții nu au putut fi încărcate",
"infoLead": "Lista UPG-100 pe o singură hartă: popoare neatinse, grupate în zone pe care le poți adopta în rugăciune.",
"infoTakeaway": "pentru fiecare zonă, un poster de perete, un pachet de rugăciune de împărțit și un set de cartonașe de rugăciune. Exemplul de mai jos este zona cu cei mai mulți oameni din popoare în care nimeni nu plantează încă biserici."
"infoTakeaway": "pentru fiecare zonă, un poster de perete, un pachet de rugăciune de împărțit și un set de cartonașe de rugăciune. Exemplul de mai jos este zona cu cei mai mulți oameni din popoare în care nimeni nu plantează încă biserici.",
"selectionColourGroup": "Culoarea selecției",
"selectionColour": "Colorează selecția {hex}",
"customColour": "Culoare personalizată (#rrggbb)",
"copiedSelection": "Copiat — popoare: {count}",
"copiedTerritoriesOnly": "Au fost copiate doar teritoriile — {reason}",
"csvSaved": "CSV salvat — popoare: {count}",
"csvFailed": "Exportul CSV a eșuat: {reason}",
"copySelection": "Copiază",
"exportCsv": "Exportă CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Плакаты есть для каждой территории в легенде — для выбора на карте используйте «Молитвенный список»",
"mapDataFailed": "Не удалось загрузить данные карты",
"infoLead": "Список UPG-100 на одной карте: недостигнутые народы, объединённые в зоны, которые вы можете взять на молитвенное попечение.",
"infoTakeaway": "для каждой зоны — настенный плакат, молитвенный буклет для раздачи и набор молитвенных карточек. Ниже показана зона, где больше всего людей в народах, среди которых пока никто не основывает церкви."
"infoTakeaway": "для каждой зоны — настенный плакат, молитвенный буклет для раздачи и набор молитвенных карточек. Ниже показана зона, где больше всего людей в народах, среди которых пока никто не основывает церкви.",
"selectionColourGroup": "Цвет выделения",
"selectionColour": "Окрасить выделение: {hex}",
"customColour": "Свой цвет (#rrggbb)",
"copiedSelection": "Скопировано — народы: {count}",
"copiedTerritoriesOnly": "Скопированы только территории — {reason}",
"csvSaved": "CSV сохранён — народы: {count}",
"csvFailed": "Не удалось экспортировать CSV: {reason}",
"copySelection": "Копировать",
"exportCsv": "Экспорт в CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "海报按图例中的区域提供——地图上的选择请使用“祷告名单”",
"mapDataFailed": "无法加载地图数据",
"infoLead": "UPG-100 名单尽在一张地图上:未得之民按区域分组,你可以在祷告中认领一个区域。",
"infoTakeaway": "每个区域都有一张墙面海报、一本可分发的祷告手册和一套祷告卡。下面的示例是这样一个区域:在尚无人植堂的族群中,这里的人数最多。"
"infoTakeaway": "每个区域都有一张墙面海报、一本可分发的祷告手册和一套祷告卡。下面的示例是这样一个区域:在尚无人植堂的族群中,这里的人数最多。",
"selectionColourGroup": "所选区域的颜色",
"selectionColour": "将所选区域着色为 {hex}",
"customColour": "自定义颜色(#rrggbb)",
"copiedSelection": "已复制 — 族群:{count}",
"copiedTerritoriesOnly": "仅复制了区域 — {reason}",
"csvSaved": "CSV 已保存 — 族群:{count}",
"csvFailed": "CSV 导出失败:{reason}",
"copySelection": "复制",
"exportCsv": "导出 CSV"
}

View file

@ -222,6 +222,7 @@
"check:label-key-coverage": "node internal/check-label-key-coverage.mjs",
"check:host-locale-fetch": "node internal/check-host-locale-fetch.mjs",
"check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs",
"check:batch-selection-export": "node internal/check-batch-selection-export.mjs",
"check:zone-title-tier-a": "node internal/check-zone-title-tier-a.mjs",
"check:evangelical-share": "node internal/check-evangelical-share.mjs",
"check:map-labels": "node internal/check-map-labels.mjs",
@ -250,6 +251,7 @@
"check:poster-fetch-guard": "node internal/check-poster-fetch-guard.mjs",
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs",
"check:posters-exportable": "node internal/check-posters-exportable.mjs",
"check:posterverse-shelf": "node plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs",
"check:poster-prints": "node internal/check-poster-prints.mjs",
"check:poster-raster": "node internal/check-poster-raster.mjs",
"check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs",
@ -342,7 +344,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -551,6 +553,7 @@
"check:label-key-coverage": "node internal/check-label-key-coverage.mjs",
"check:host-locale-fetch": "node internal/check-host-locale-fetch.mjs",
"check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.",
"check:batch-selection-export": "UPG-100's batch selection EXPORTS its selection (restored 2026-09-30): Copy's first line parses in data/apply-grouping.mjs (names, UUPG total, colour) with the people-group CSV after it; the CSV header, quoting and formula-neutralising hold under a real CSV parse; the HUD wires the colour swatches, hex input, Copy, Export CSV and the records prefetch; the 9 new strings are in all 11 locales, translated. Mutation-tested (CSV first, no formula guard, Copy unwired: each FAILS). Headless; in gates:pre. By the UPG-100 owner.",
"check:tree-relative": "the committed tree names no host of its own. With embedBaseUrl empty in the build config, the manifest must say embedBase \".\" and every scriptUrl, iframeUrl and embed snippet src must be relative. check:drop cannot see this: it looks for root-relative \"/...\" references and skips https:// ones, so a tree built with EMBED_BASE_URL (a deploy build) would pass it and pin every snippet to one host, the way the retired CDN route broke. Data addresses (runtime.apiBaseUrl) are not checked. SKIPs when the config sets embedBaseUrl. Static; takes an optional manifest path for poison tests.",
"check:universal-props": "every plugin reads the props the Plugin Studio header sets for all of them through the library: map engine, locale, Mapbox token and token URL, and the people-group list. It reads each bundle's source AND its shipped bundle and fails on a bypass (a mapbox:// style literal, a private token global, a list fetched around the library); a by-design exception is ALLOWed by name with its reason. --report also prints the matrix. Headless.",
"check:zone-title-tier-a": "generated zone titles ship for ONE-country zones only: the 3 such zones produce a localized, non-code title in en/es/ar that differs across languages, AND every other zone declines so its hand-written label survives unchanged (a multi-country zone has a name that is not a list of its members). Two-sided, and NOT MEASURED when the country-name supplier cannot resolve a known code.",
@ -572,6 +575,7 @@
"check:poster-layout-optin": "the composed poster sheet (header, map, legend beside it, footer) is OPT-IN: spec.layout === 'poster' asks for it, and every caller that does not gets the full-page map exactly as before, asserted by object identity, not just by value. Drives the real layout decisions with real spec objects. Headless, no browser.",
"check:poster-print-fallback": "clicks the REAL poster print control with the served poster generator and every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; NOT MEASURED otherwise.",
"check:posters-exportable": "every poster the tree offers can be printed. With posterSources on, the tree once shipped only the 28 zone sources, so 350 of 378 posters said \"poster not found (404)\". Each id, from data/posters and the wall's previews, must ship a real WebP (RIFF..WEBP) or a real poster source (its class=\"stage\" element), so a placeholder or a host's fallback page cannot pass. SKIPs when posters are off by config, which check:poster-degrades covers. Reads the built tree; no server.",
"check:posterverse-shelf": "Posterverse (the capability-5 poster shell) shows exactly its allow-list and never a gated plugin. Asserts the rail rows per section (Posters: upg-100, country-posters, poster-design; Experimental: wall-of-people-groups, all-people-groups, upg-neighbor, bible-posters), that no row names key-cities or a shell (a key-cities bundle is planted into the built manifest, because the real one withholds gated profiles), that every present row opens the profile it names (no silent fallback to a leaf), that posterverse.meta.json composes equals the rows whose bundle exists, and 11-locale parity of the shell strings. --self-test plants one defect per arm. Headless. Exit 0 pass, 1 fail, 75 no built manifest.",
"check:prayer-walk-planner": "the Prayer Walk Planner is a full-page tool that prints a real walking map: the map canvas fills the page below the toolbar in its own leaf AND inside the Research Gallery at 1280x800 and 390x844 (the gallery once collapsed it to 76px / 0px); a real zone (Balat, Istanbul) renders a >=2000px print map that is not blank, saved to OUT=<dir> so a human can read it; ?lang=es gives a Spanish toolbar; 0 page errors. Run inside the shared browser locks. Exit 2 = SKIP.",
"check:preset-config-merge": "a preset page (<bundle>/<profile>/<instance>/index.html) keeps every default in its profile's .meta.json config, with the instance's props merged over them (go-int's upg-neighbor presets lost pinHint:false when props replaced the config). Reads the built tree; SKIP without one.",
"check:profile-table": "the prayer packet's profile TABLE (20 full-width rows: picture, generated description, three boxes; PER_PAGE stays 20): a row stays tall enough for a name plus a two-line description at the smallest fit size, and truncation happens only at a whole word. Character-count, not DOM, so Node and the browser agree. Cannot see the RENDERED row height: that is check:packet-render, a browser gate. By the UPG-100 owner; mutation-tested. Headless; in gates:pre.",

View file

@ -0,0 +1,70 @@
// selection-export.js — what the batch selection's Copy and Export CSV buttons produce. PURE: no DOM, no
// fetch, so internal/check-batch-selection-export.mjs gates it headless.
//
// ★ WHY THE SELECTION IS EXPORTED AT ALL: when the people-group data changes, a partner redraws the zone
// boundaries on the map, exports, and hands the export to an agent, which regroups the zones and regenerates
// the posters. Without the export the maps cannot be reproduced.
//
// THE CLIPBOARD TEXT, in this order:
// line 1 "NPL, BTN, … — {total} UUPG · #hex" formatClipboard's line in MACHINE names (payloadName):
// data/apply-grouping.mjs parses AND resolves it, so it MUST stay first
// line 2 "Nepal 34, Bhutan 13, …" the per-territory breakdown, in the reader's names
// blank line, then the SAME CSV that Export CSV downloads (when the people groups could be detected).
//
// THE CSV: one row per people group, header first, CRLF line ends. The cells follow the library's rules
// (useCsvExport's esc): quote a cell holding , " CR or LF and double its quotes; prefix a leading = + - @
// with a single quote, so a spreadsheet never runs a name as a formula. Also a leading TAB or CR (OWASP's
// full list of formula triggers; the library's esc stops at = + - @).
import { formatClipboard } from './territory-counts.js';
export const SELECTION_CSV_COLUMNS = ['slug', 'name', 'country_code', 'population', 'territories', 'color'];
export function csvCell(v) {
let s = v === null || v === undefined ? '' : String(v);
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
return s;
}
/**
* @param {Array<{slug,name,country_code,population}>} records the detected people groups
* @param {Map<string,string[]>} territoriesBySlug slug → the selected territories that contain it
* @param {{color?: string}} opts the picked selection colour ('#rrggbb' or '')
*/
export function selectionCsv(records, territoriesBySlug = new Map(), opts = {}) {
const color = opts && typeof opts.color === 'string' ? opts.color : '';
const rows = (records || []).map((r) => [
r.slug, r.name, r.country_code, r.population,
(territoriesBySlug.get(r.slug) || []).join('; '), color,
].map(csvCell).join(','));
return [SELECTION_CSV_COLUMNS.join(','), ...rows].join('\r\n');
}
// ★ LINE 1 IS A MACHINE CONTRACT: data/apply-grouping.mjs resolves every name in it, so it carries the names that
// tool resolves in EVERY UI language, never the reader's translation (an Arabic "نيبال, بوتان" was rejected:
// "unresolved territory name(s)"). A country is its ISO3 code, the only country form apply-grouping resolves for
// every country (its friendly-name table has ~15); a state or province is its shape name, already canonical; a
// zone keeps its name. The reader's own names stay on line 2, in the HUD and in the CSV.
export function payloadName(t) {
if (t && t.kind === 'adm0') return String(t.key || '').replace(/^adm0:/, '') || t.name;
return t ? t.name : '';
}
/** The clipboard text: apply-grouping's line (machine names), the reader's breakdown, then the CSV after a blank line. */
export function selectionClipboard(selection, opts = {}, csv = '') {
const color = opts && opts.color ? opts.color : '';
const sel = Array.isArray(selection) ? selection : [];
const line1 = formatClipboard(sel.map((t) => ({ ...t, name: payloadName(t) })), { color }).split('\n')[0];
const line2 = formatClipboard(sel, { color }).split('\n')[1];
const head = line1 + '\n' + line2;
return csv ? head + '\n\n' + csv : head;
}
/** A file name a partner can recognise: the selection's label, reduced to safe characters. */
export function selectionCsvFileName(label) {
const base = String(label || 'selection').normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase().slice(0, 60) || 'selection';
return `upg-100-${base}.csv`;
}
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, selectionCsv, selectionClipboard, selectionCsvFileName };

View file

@ -128,6 +128,7 @@ import { colorForZone } from './data/zone-colors.js'
// clicked tapestry feature carries shapeName (ADM1) OR iso3 (ADM0) and this maps
// either to { key, name, count } from admin-counts/membership (NEVER edits them).
import { resolveTerritory, buildCountryNameMap, formatClipboard, ADM1_COUNTS, ISO3_NAME } from './data/territory-counts.js'
import { selectionCsv, selectionClipboard, selectionCsvFileName } from './data/selection-export.js'
// 09b-search-bar §1(a-c): unified search index (PG + zone + territory) + the
// tokenized contains matcher. Pure data + pure fns; this profile builds the rows
// from the live records (PG), the legend leafZones (zone), and admin-counts +
@ -258,6 +259,8 @@ function setBatchMode(on) {
if (next === batchMode.value) return next // idempotent: repeat sets don't churn
batchMode.value = next
if (map) map.getCanvas().style.cursor = '' // reset hover cursor on mode flip
// Warm the people-group records so Copy / Export CSV run from cache inside the tap (see selectionRecordsLoad).
if (next) selectionRecordsLoad().catch(() => {})
// R1.4: show/hide ALL batch chrome (map highlight + legend markers) in the SAME
// pass as the mode flip — no frame where the mode and its chrome disagree.
refreshSelectionPaint()
@ -1814,65 +1817,65 @@ function selectedAdm1Names() {
.map((t) => t.name || String(t.key).replace(/^adm1:/, ''));
}
async function selectionAsList() {
// ── THE SELECTION'S PEOPLE, detected ONCE for every action (packet, cards, Copy, Export CSV) ─────────
// The records load once per language and are CACHED: batch mode prefetches them when it turns on, so a
// Copy tap builds its text from cache and writes it inside the tap's user activation (a clipboard write
// after a network wait is refused, on phones above all). A failed load is dropped so the next try refetches.
let _selRecords = null; // { lang, promise }
function selectionRecordsLoad() {
const lang = packetLang();
if (_selRecords && _selRecords.lang === lang) return _selRecords.promise;
if (!API_BASE) return Promise.reject(keyedError('wall.hud.noApi', 'no people-groups service is configured'));
const fields = PRINT_FIELDS; // the print standard's one field list (lists/peopleGroupRecords.js)
const promise = (async () => {
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${lang}`, { cache: 'no-store' });
if (!resp.ok) throw keyedError('wall.hud.fetchFailed', 'could not load the people groups ({status})', { status: resp.status });
return normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
})();
_selRecords = { lang, promise };
promise.catch(() => { if (_selRecords && _selRecords.promise === promise) _selRecords = null; });
return promise;
}
// { records, from: Map(slug → the selected territories that contain it), missing: adm1 names } in the
// SELECTION'S order: a zone's groups, then a country's, each record once. Territory names are the HUD's own
// (selectedTerritories[].name), the same names the clipboard's first line carries.
async function selectionPeople() {
const lists = selectedLists();
const codes = selectedCountryCodes();
const adm1 = selectedAdm1Names();
if (!lists.length && !codes.length) {
// Name WHY when the only thing selected is a province — "nothing selected" would be a lie the
// reader can see is false, with their selection visibly highlighted on the map.
// reader can see is false.
if (adm1.length) throw keyedError('upgMap.selectionAdm1NoData', 'no people-group data for {names} — select the country instead', { names: formatList(adm1, { locale: packetLang() }) });
throw keyedError('upgMap.nothingSelected', 'nothing is selected');
}
if (!API_BASE) throw keyedError('wall.hud.noApi', 'no people-groups service is configured');
// ★ primary_language + imb_gsec FEED THE GENERATED PROFILE DESCRIPTION (brief A1.5 col 2,
// 2026-09-28). describeGroup builds a localized sentence — who, where, what language, how reached —
// and GSEC is the only reachedness field that is a NUMBER: EvangelicalLevel is English prose from the
// API ('2% or Greater but Less than 5%'), so interpolating it would leave every non-English packet
// half-English in its widest column (sr measured this across 12,379 rows). primary_language is the
// same field poster-wall already fetches (:171), so this adds no new API surface.
// ★ wagf_region + wagf_block ARE SELECTOR INPUTS, not display fields. SIX of the 27 printable
// zones select on them — west-africa / east-africa / central-africa (wagf_block), europe
// (wagf_region), southern-africa-islands (union of two wagf_block) and oceania-indonesia (union of
// wagf_region + country_code). Without them here, evaluate() matched NOTHING on packet rows and
// those packets printed EMPTY, while the same selectors resolved correctly on the map — whose
// fetch (:1988) has always carried both. Measured: a wagf_block selector returns 0 members on
// packet rows and 1 on map rows. oceania-indonesia was the worst of the six because its union
// still matched IDN, so it printed a PLAUSIBLE packet that was silently missing all of Oceania.
const fields = PRINT_FIELDS; // the print standard's one field list (lists/peopleGroupRecords.js)
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`,
{ cache: 'no-store' });
if (!resp.ok) throw keyedError('wall.hud.fetchFailed', 'could not load the people groups ({status})', { status: resp.status });
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
const records = await selectionRecordsLoad();
const { evaluate } = await import('./data/selector-evaluator.js');
const slugs = new Set();
for (const l of lists) {
if (l.selector && l.selector.type === 'pg_slugs') {
for (const s of (l.selector.values || [])) slugs.add(s);
} else {
for (const s of evaluate(l.selector, records)) slugs.add(s);
const bySlug = new Map(records.map((r) => [r.slug, r]));
const byId = new Map(printableZones().map((l) => [l.id, l]));
const from = new Map();
const add = (slug, name) => {
const at = from.get(slug);
if (!at) from.set(slug, [name]);
else if (!at.includes(name)) at.push(name);
};
for (const t of selectedTerritories.value) {
if (t.kind === 'zone') {
const l = byId.get(String(t.key).replace(/^zone:/, ''));
if (!l) continue;
const slugs = l.selector && l.selector.type === 'pg_slugs' ? (l.selector.values || []) : evaluate(l.selector, records);
for (const s of slugs) add(s, t.name);
} else if (t.kind === 'adm0') {
for (const s of evaluate({ type: 'country_codes', values: [String(t.key).replace(/^adm0:/, '')] }, records)) add(s, t.name);
}
}
// The tapped countries, through the SAME evaluator and into the SAME slug set, so a mixed
// selection (a legend zone AND a country) unions correctly and de-duplicates for free.
if (codes.length) {
for (const s of evaluate({ type: 'country_codes', values: codes }, records)) slugs.add(s);
}
// The label is what the reader sees on the cover and in the print dialog's title. Name the
// territories they actually chose rather than inventing a title for their selection.
// ⚠ THE JOINERS WERE HARDCODED ENGLISH on a cover an Arabic reader sees: ' + ' and ' +N more', printed
// straight onto the packet's first page and into the print dialog's title. Found while checking whether
// batch selection was already done (dx-shepherd END-DXS-U100B) — the SELECTION path was, but its label
// was not localized, so a reader choosing three territories in Arabic got their names in Arabic joined
// by an English tail.
// zone_join / zone_join_more already exist in all eleven packet-strings blocks (landed with the zone
// title generator), translated once for exactly this shape, so this is a reuse rather than new strings.
// ⚠ NAME THE COUNTRIES TOO. Built from `lists` alone this was EMPTY for a country-only selection,
// and names.reduce(join2) on an empty array THROWS ("Reduce of empty array with no initial value")
// — so even after the selector fix the packet would have died on its cover. The names come from
// selectedTerritories in the reader's own selection order, which is the order the cover promises.
// Each chosen zone named as its own packet would title it — generated in the reader's language where
// it can be (one-country zones), the hand-written label otherwise.
return { records: [...from.keys()].map((s) => bySlug.get(s)).filter(Boolean), from, missing: adm1 };
}
async function selectionAsList() {
const lists = selectedLists();
const { from } = await selectionPeople();
const zoneNames = (await Promise.all(lists.map(localizedZone))).map((l) => l.label || l.id);
const countryNames = selectedTerritories.value.filter((t) => t.kind === 'adm0').map((t) => t.name);
const names = [...zoneNames, ...countryNames];
@ -1882,8 +1885,84 @@ async function selectionAsList() {
? names.reduce(join2)
: fillJoin(S.zone_join_more, { a: names.slice(0, 2).reduce(join2), n: names.length - 2 })
?? `${names.slice(0, 2).join(' + ')} +${names.length - 2} more`;
return { id: 'selection', label, count: slugs.size,
selector: { type: 'pg_slugs', values: [...slugs] } };
return { id: 'selection', label, count: from.size,
selector: { type: 'pg_slugs', values: [...from.keys()] } };
}
// ── COPY + EXPORT CSV (restored 2026-09-30; see data/selection-export.js for WHY and the formats) ────────
// COPY starts SYNCHRONOUSLY: the text is a promise handed to ClipboardItem, which Safari and Chrome accept
// inside the tap even when the people groups are still loading. Where that is refused it falls back to
// writeText, then to the textarea path. A selection whose people groups cannot be detected (a province)
// still copies the territory lines that data/apply-grouping.mjs needs, and the flash says what was left out.
function selectionNote(missing) {
return missing && missing.length
? t('upgMap.selectionAdm1NoData', { names: formatList(missing, { locale: packetLang() }) }) : '';
}
function copySelection() {
const sel = selectedTerritories.value;
if (!sel.length) return;
const color = selectedColor.value;
let count = null, note = '';
const text = selectionPeople().then((p) => {
count = p.records.length; note = selectionNote(p.missing);
return selectionClipboard(sel, { color }, selectionCsv(p.records, p.from, { color }));
}, (e) => { note = errorText(e, t); return selectionClipboard(sel, { color }); });
text.then((v) => { try { window.__UPG_LAST_CLIPBOARD__ = v } catch (_) {} });
writeClipboard(text).then((ok) => {
const done = count == null ? t('upgMap.copiedTerritoriesOnly', { reason: note.slice(0, 90) })
: t('upgMap.copiedSelection', { count }) + (note ? ' · ' + note.slice(0, 90) : '');
flashRef(copyFlash, ok ? done : t('messages.copyFailed'), ok && note ? 6000 : 3000);
});
}
async function writeClipboard(textPromise) {
const nav = typeof navigator !== 'undefined' ? navigator : null;
if (nav && nav.clipboard && nav.clipboard.write && typeof ClipboardItem !== 'undefined') {
try {
await nav.clipboard.write([new ClipboardItem({ 'text/plain': textPromise.then((v) => new Blob([v], { type: 'text/plain' })) })]);
return true;
} catch (_) { /* refused (e.g. no promise support): the next paths */ }
}
const text = await textPromise;
try {
if (nav && nav.clipboard && nav.clipboard.writeText) { await nav.clipboard.writeText(text); return true; }
} catch (_) { /* no permission or no activation left: the textarea path */ }
return legacyCopy(text);
}
// textarea + execCommand('copy'): a non-secure context has no navigator.clipboard at all.
function legacyCopy(text) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0'; ta.style.top = '0';
document.body.appendChild(ta);
ta.focus(); ta.select();
const ok = document.execCommand && document.execCommand('copy');
document.body.removeChild(ta);
return !!ok;
} catch (_) { return false; }
}
// EXPORT CSV downloads the same CSV Copy carries. A UTF-8 byte-order mark leads the file so a spreadsheet
// reads the diacritics and non-Latin names as UTF-8 rather than guessing a legacy code page.
async function exportSelectionCsv() {
if (batchBusy.value) return;
batchBusy.value = true;
busyAction.value = 'csv';
try {
const p = await selectionPeople();
const color = selectedColor.value;
const csv = selectionCsv(p.records, p.from, { color });
try { window.__UPG_LAST_CSV__ = csv } catch (_) {}
const url = URL.createObjectURL(new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = selectionCsvFileName(selectedTerritories.value.map((x) => x.name).join(' '));
a.style.display = 'none';
document.body.appendChild(a); a.click();
setTimeout(() => { try { URL.revokeObjectURL(url); a.remove(); } catch (_) {} }, 400);
const note = selectionNote(p.missing);
flashRef(copyFlash, t('upgMap.csvSaved', { count: p.records.length }) + (note ? ' · ' + note.slice(0, 90) : ''), note ? 6000 : 3000);
} catch (e) {
flashRef(copyFlash, t('upgMap.csvFailed', { reason: errorText(e, t).slice(0, 90) }), 5000);
} finally { batchBusy.value = false; busyAction.value = ''; }
}
async function makeSelectionPacket() {
@ -3009,6 +3088,9 @@ onBeforeUnmount(() => {
if (window.__UPG_SEARCH__ === _mySearchHandle) delete window.__UPG_SEARCH__
if (window.__UPG_MAP__ === _myMapHandle) delete window.__UPG_MAP__
if (window.__UPG_SELECT__ === _mySelectHandle) delete window.__UPG_SELECT__
// Copy / Export CSV's last text (the gates read them back); released with the rest of this map's hooks.
delete window.__UPG_LAST_CLIPBOARD__
delete window.__UPG_LAST_CSV__
} catch (_) { /* a frozen or cross-origin window must not break teardown */ }
}
})
@ -3232,12 +3314,22 @@ onBeforeUnmount(() => {
</span>
</div>
<div v-if="selCount" class="upg-hud-names">{{ formatList(selectedTerritories.map(t => t.name), { locale: packetLang() }) }}</div>
<!-- ★ THE SELECTION MAKES SOMETHING. The batch tool was reported very buggy, and letting
a reader change the colour of regions is NOT what is wanted — the batch selection tool
should let them create their own poster or prayer packet from their own selection.
So the colour swatches and the hex input are GONE, and the actions that remain BUILD
the two artefacts this app already knows how to make, from exactly what was picked.
These reuse printPrayerPacket / printPoster rather than growing a third pipeline. -->
<!-- ★ RECOLOUR IS BACK (2026-09-30), beside the actions that build from the selection. The batch tool
exists so that when the people-group data changes, partners can redraw the zones and re-derive
their people groups: the colour previews on the map (the upg-sel-fill layer repaints; the base map
stays clean) and travels in Copy's first line as " · #hex", which data/apply-grouping.mjs applies
to the regrouped zone. 8 Okabe–Ito swatches (colour-blind aware) + a custom hex. -->
<div v-if="selCount" class="upg-hud-swatches" role="group" :aria-label="t('upgMap.selectionColourGroup')">
<button v-for="c in SWATCHES" :key="c" type="button"
class="upg-swatch" :class="{ active: selectedColor === c }" :aria-pressed="selectedColor === c ? 'true' : 'false'"
:data-color="c" :style="{ background: c }"
:title="t('upgMap.selectionColour', { hex: c })" :aria-label="t('upgMap.selectionColour', { hex: c })"
@click="setSelectionColor(c)"></button>
<input v-model="hexDraft" class="upg-hex" placeholder="#hex" spellcheck="false"
:aria-label="t('upgMap.customColour')" @change="onHexInput" @keyup.enter="onHexInput" />
</div>
<!-- The make-row BUILDS what this app already makes from exactly what was picked (printPrayerPacket /
printPoster / the card deck); the data-row below EXPORTS the selection itself. -->
<!-- ★ THE BUTTON SAYS WHAT IT IS DOING. Reported from mobile 2026-09-28: pressing these gave
no sign of progress. batchBusy only DISABLED them, and a disabled button is visually
indistinguishable from an unresponsive one — so a reader on a phone taps, sees the label
@ -3252,6 +3344,13 @@ onBeforeUnmount(() => {
@click="makeSelectionPoster">{{ busyAction === 'poster' ? t('buttons.working') : t('buttons.posterMap') }}</button>
<button class="upg-hud-btn make" :class="{ 'is-busy': busyAction === 'cards' }" :disabled="batchBusy"
@click="makeSelectionCards">{{ busyAction === 'cards' ? t('buttons.working') : t('buttons.cardDeck') }}</button>
</div>
<!-- COPY + EXPORT CSV: the selection's territories (apply-grouping's line) and its people groups
(data/selection-export.js). A second row, so six buttons never squeeze into one on a phone. -->
<div v-if="selCount" class="upg-hud-actions">
<button class="upg-hud-btn copy" @click="copySelection">{{ t('upgMap.copySelection') }}</button>
<button class="upg-hud-btn csv" :class="{ 'is-busy': busyAction === 'csv' }" :disabled="batchBusy"
@click="exportSelectionCsv">{{ busyAction === 'csv' ? t('buttons.working') : t('upgMap.exportCsv') }}</button>
<button class="upg-hud-btn clear" @click="clearSelection">{{ t('buttons.clearSelection') }}</button>
</div>
<div v-if="selCount" class="upg-hud-flash">{{ copyFlash }}</div>

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-maps · posterverse staging</title>
<link rel="icon" href="data:,">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: #f3f3f1; color: #3b463d;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
posterverse-app { display: block; position: fixed; inset: 0; width: 100%; height: 100%; }
</style>
</head>
<body>
<!-- Capability 5, the app shell: one element, <posterverse-app>, is a vertical-tab rail of every
poster plugin. Each tab loads its plugin's own script lazily on first open. -->
<posterverse-app></posterverse-app>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,37 @@
/**
* posterverse / index.js — bundle entry (capability 5, nested-mfe-spa / app shell).
*
* ONE folder -> ONE bundle -> ONE custom element: <posterverse-app>. (A custom-element name needs a
* hyphen, so the tag cannot be the bare folder name.)
*
* It registers only its own tag. It never imports another plugin's components and never re-registers a
* tag another bundle owns: each nested poster plugin registers its own tag when its script loads.
*
* TREE BASE: to find each nested plugin's script, the shell needs the built tree's root URL. It is
* derived HERE, at IIFE load, from this script's own URL (.../<tree>/posterverse/posterverse.js, two
* levels up), so the element self-locates wherever the tree is served. A host can override it with
* the tree-base="" attribute or profile-config.treeBase.
*
* Usage from a host page:
* <script src="/dx-maps/posterverse/posterverse.js"></script>
* <posterverse-app></posterverse-app>
*/
import { defineCustomElement } from 'vue'
import Posterverse from './posterverse.vue'
import { deriveTreeBase } from '@map/utils/embedScript.js'
// Captured synchronously at IIFE load, while document.currentScript is still this bundle's tag.
const TREE_BASE = deriveTreeBase()
const PosterverseElement = defineCustomElement(Posterverse, {
configureApp(app) {
app.provide('pvTreeBase', TREE_BASE)
}
})
if (typeof customElements !== 'undefined' && !customElements.get('posterverse-app')) {
customElements.define('posterverse-app', PosterverseElement)
}
export default PosterverseElement

View file

@ -0,0 +1,16 @@
{
"_comment": "One preset of the capability-5 shell. provider:'maplibre' marks the generated standalone page TOKEN-FREE (generate-tree TOKEN_FREE), so the shell mounts at once: the shell paints no map itself, its tabs do, each with its own token ladder. The meta.json type still wins over the preset signal, so this stays capability 5.",
"profile": "posterverse",
"instances": [
{
"id": "app",
"title": "Posterverse — every poster plugin (app shell)",
"props": {
"profile": "posterverse",
"provider": "maplibre",
"instanceId": "posterverse-app",
"heading": "Posterverse"
}
}
]
}

View file

@ -0,0 +1,12 @@
{
"_comment": "Declares the composability tier. 'nested-mfe-spa' = capability 5: an app shell whose single element <posterverse-app> renders a vertical-tab rail of whole poster plugins, each mounted as its own real element from the manifest (no iframes). The tier is DERIVED from `composes` at tree generation (composing a cap-4 makes this cap-5) and this `type` is checked against it. `composes` lists every row in src/tabs.js whose bundle exists today; a provisional row (poster-design, bible-posters) joins the list when its bundle lands, since an unresolved ref fails check:capability-derived.",
"type": "nested-mfe-spa",
"composes": [
"my-upg-100-list/upg-100-mfe",
"country-posters/country-posters-mfe",
"wall-of-people-groups/wall-of-people-groups",
"all-people-groups/all-people-groups",
"upg-neighbor/upg-neighbor"
],
"label": "Posterverse (vertical-tab app shell: every poster plugin)"
}

View file

@ -0,0 +1,397 @@
<!--
posterverse.vue — the second capability-5 app shell (nested-mfe-spa): every poster plugin in one place.
* @description Every poster plugin in one app: a vertical-tab rail of real, embeddable poster plugins.
Tabs: UPG 100 List Posters, Country Posters, Poster Design, and an Experimental folder of the
poster-related experimental plugins. The same pattern as the Research Gallery (doxa-maps-spa),
reused rather than copied: the shared VerticalTabsLayout, bundle+profile identities resolved from
the tree's manifest, the library's loadScriptOnce/whenTagDefined, and the same profile-config
contract for every child.
WHAT IS DIFFERENT FROM THE GALLERY, on purpose:
- The rail is an ALLOW-LIST (src/tabs.js), Experimental included. The gallery's Experimental is
"every bundle nobody placed", which here would expose the gated key-cities.
- No MapInfo row, no hosted "?" and no shell-level phone notice. Panes do NOT carry
data-doxa-gallery or data-doxa-help, so each child keeps its OWN info popup and its own phone
notice (ProfileLoader / useScreenGate) instead of deferring to a host that has none.
ONE tag, <posterverse-app>, is defined by this bundle (index.js). It never imports a child's
components and never registers a child's tag: each child's own script does that.
-->
<template>
<VerticalTabsLayout
:dir="uiDir"
:lang="uiLocale"
:labels="layoutLabels"
:title="headingText"
:nav-label="tr('sec.posters')"
:header="false"
:groups="navGroups"
:active="activeKey"
@update:active="onSelect">
<!-- Every tab mounts its plugin's real custom element into an EMPTY, Vue-unmanaged slot, after that
bundle's script is injected once. The hint is a separate sibling, so clearing the slot can never
remove a Vue-managed node.
LIGHT rows (v-show) stay mounted once loaded, so switching back is instant. HEAVY rows (anything
that owns or nests a WebGL map) are rendered with v-if and DESTROYED on deactivate, which runs the
element's own teardown (map.remove()) and keeps the document to about one live map context. -->
<section
v-for="(t, i) in tabs" :key="t.id" class="pv-pane"
:ref="el => { if (el) paneEls[i] = el; else delete paneEls[i] }"
v-show="i === activeIndex" :hidden="i !== activeIndex">
<template v-if="!t.heavy || i === activeIndex">
<div class="pv-slot" :ref="el => setSlot(i, el)"></div>
<div v-if="t.state === 'loading'" class="pv-hint">{{ tr('loading', { label: tabLabel(t) }) }}</div>
<div v-else-if="t.state === 'error'" class="pv-hint err">
{{ tr('loadFailed', { label: tabLabel(t) }) }}<br><small>{{ t.error }}</small>
</div>
<!-- No manifest means no scriptUrl, so the row can never mount: say so rather than show an empty pane. -->
<div v-else-if="t.state === 'idle' && !t.scriptUrl && manifestError" class="pv-hint err">
{{ tr('loadFailed', { label: tabLabel(t) }) }}<br>
<small>{{ manifestError }}</small><br>
<small>{{ tr('manifestHint') }}</small>
</div>
</template>
</section>
<!-- The pane is the scroller (overflow-y:auto), so back-to-top follows the active pane. -->
<BackToTopButton :target="activePaneEl" :labels="{ backToTop: tr('backToTop') }" />
</VerticalTabsLayout>
</template>
<script>
import { defineComponent, inject } from 'vue'
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
import BackToTopButton from '@map/components/BackToTopButton.vue'
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
import { translate, LOCALES } from './src/strings.js'
import { SECTIONS, TABS, resolveTabs } from './src/tabs.js'
// The host page's language by the ladder every nested map uses: ?lang= / ?locale=, then <html lang>.
// '' when the page says nothing. Never navigator.language: a browser's language must not override the
// language of the page the shell is embedded in.
function detectHostLocale() {
try {
const q = new URLSearchParams(location.search)
const fromUrl = (q.get('lang') || q.get('locale') || '').split('-')[0].toLowerCase()
if (LOCALES.includes(fromUrl)) return fromUrl
} catch { /* no window */ }
try {
const fromHtml = (document.documentElement.lang || '').split('-')[0].toLowerCase()
if (LOCALES.includes(fromHtml)) return fromHtml
} catch { /* no document */ }
return ''
}
const freshTab = (d) => ({ ...d, tag: d.tag || '', scriptUrl: d.scriptUrl || '', heavy: !!d.heavy, mounted: false, state: 'idle', error: '' })
export default defineComponent({
name: 'Posterverse',
// The host's attributes stay on the host: an inline style copied onto the layout root beats the
// layout's own rules (a styled host once collapsed a nested map to 76px).
inheritAttrs: false,
components: { VerticalTabsLayout, BackToTopButton },
props: {
treeBase: { type: String, default: '' },
profileConfig: { type: [String, Object], default: '' },
// Not `title`: that is a native HTMLElement property, and defineCustomElement maps props to attributes.
heading: { type: String, default: 'Posterverse' },
tokenUrl: { type: String, default: '' },
tk: { type: String, default: '' },
// The language, inherited from the host (canonical prop). profile-config.locale / lang also work.
language: { type: String, default: '' },
apiBaseUrl: { type: String, default: '' },
// The engine for every nested map: 'maplibre' or 'mapbox'. Unset = each map's own ladder.
provider: { type: String, default: '' },
// Which tab opens first, by row id (src/tabs.js). Also ?tab= on the page URL.
tab: { type: String, default: '' },
},
emits: ['tab-change'],
setup() {
return { injectedBase: inject('pvTreeBase', '') }
},
data() {
return {
paneEls: {},
activeIndex: 0,
manifestError: '',
// The directory the manifest answered from (trailing slash). Tree-relative scriptUrls resolve
// against THIS, which differs from the document under the dev server.
manifestBase: '',
// The manifest's `runtime` block (the build's apiBaseUrl + public token), for foreign-page embeds.
runtime: {},
hostLang: detectHostLocale(),
// Until the manifest lands, the rail shows the rows that are expected to exist; provisional rows
// appear only once the manifest proves their bundle is in this tree.
tabs: TABS.filter((d) => !d.provisional).map(freshTab),
}
},
computed: {
cfg() {
let pc = this.profileConfig
if (typeof pc === 'string') { try { pc = pc ? JSON.parse(pc) : {} } catch { pc = {} } }
return pc && typeof pc === 'object' ? pc : {}
},
// The tree root with a trailing slash: host override, then the base index.js derived from its own
// <script> URL, then '' (relative: sibling bundle folders are one level up).
base() {
const raw = String(this.treeBase || this.cfg.treeBase || this.injectedBase || '').trim()
return raw ? raw.replace(/\/+$/, '') + '/' : ''
},
// The tree's asset pool, ABSOLUTE, for every child. Without it a nested element climbs to a sibling
// pool the publisher does not ship.
assetsBase() {
const v = String(this.cfg.assetsBase || '').trim()
if (/^https?:\/\//i.test(v) || v.startsWith('/')) return v.replace(/\/+$/, '')
return this.manifestBase ? this.manifestBase.replace(/\/+$/, '') + '/assets' : ''
},
headingText() { return String(this.cfg.heading || this.heading || '').trim() || 'Posterverse' },
// Language precedence: what the host says, then the host page's own language, then English.
effectiveLocale() {
const fromProp = String(this.language || this.cfg.language || this.cfg.locale || this.cfg.lang || '').trim()
return (fromProp || this.hostLang || 'en').split('-')[0].toLowerCase()
},
uiLocale() { return LOCALES.includes(this.effectiveLocale) ? this.effectiveLocale : 'en' },
uiDir() { return this.uiLocale === 'ar' ? 'rtl' : 'ltr' },
layoutLabels() {
const out = {}
for (const k of ['toggleList', 'openViews', 'expandList', 'collapseList', 'collapse']) out[k] = this.tr(k)
return out
},
effectiveProvider() {
const p = String(this.provider || this.cfg.provider || '').trim().toLowerCase()
return (p === 'maplibre' || p === 'mapbox') ? p : ''
},
activeKey() { const t = this.tabs[this.activeIndex]; return t ? t.id : '' },
activePaneEl() { return this.paneEls[this.activeIndex] || null },
// The grouped rail: SECTIONS projected over the rows that exist. An empty section is omitted.
navGroups() {
return SECTIONS.map((g) => ({
key: g.key,
label: this.tr(g.i18n),
icon: g.icon,
folder: !!g.folder,
open: g.open,
rows: this.tabs.filter((t) => t.section === g.key).map((t) => ({
key: t.id, label: this.tabLabel(t), sub: this.tabSub(t), icon: t.icon,
badge: g.folder ? this.tr('lab') : undefined,
})),
})).filter((g) => g.rows.length)
},
},
watch: {
// A language change REMOUNTS: not every child re-reads its config live, but a fresh element reads
// the new locale at mount. Hidden mounted panes are reset so their next open is fresh too.
effectiveLocale() {
const reset = (i, t) => {
try { const slot = this._slots && this._slots[i]; if (slot) slot.innerHTML = '' } catch { /* reset anyway */ }
t.mounted = false; t.state = 'idle'; t.error = ''
}
this.tabs.forEach((t, i) => { if (t.mounted && i !== this.activeIndex) reset(i, t) })
const t = this.tabs[this.activeIndex]
if (!t || !t.mounted) return
reset(this.activeIndex, t)
this.mountElement(this.activeIndex)
},
tab(id) { if (id) this.onSelect(id) },
},
mounted() {
this.watchHostLang()
// Paint the rail at once; the manifest completes the rows in the background and re-drives the mount.
this.select(this.initialIndex())
this.loadManifest()
},
beforeUnmount() {
if (this._langObs) { this._langObs.disconnect(); this._langObs = null }
},
methods: {
tr(key, params) { return translate(this.uiLocale, key, params) },
tabLabel(t) { return this.tr('tab.' + t.id) },
tabSub(t) { return t.section === 'experimental' ? this.tr('experimentalSub') : this.tr('sub.' + t.id) },
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
watchHostLang() {
if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return
this._langObs = new MutationObserver(() => { this.hostLang = detectHostLocale() })
this._langObs.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
},
requestedTab() {
let q = ''
try { q = new URLSearchParams(location.search).get('tab') || '' } catch { /* non-browser */ }
return String(this.tab || this.cfg.tab || q || '').trim()
},
initialIndex() {
const i = this.tabs.findIndex((t) => t.id === this.requestedTab())
return i >= 0 ? i : 0
},
// Read the manifest once. Candidates, in order:
// 1. <tree>/manifest.json (same origin);
// 2. <tree>/doxa-maps-spa/manifest.json, the byte-identical copy generate-tree writes for the
// Research Gallery. The live static layer sends CORS headers only inside a tree folder, not
// on the root manifest, so a partner page's script embed reads this copy FIRST;
// 3. /public/<tree>/manifest.json, where the dev server keeps the built manifest.
// Script URLs always resolve against the tree ROOT, never the copy's folder.
loadManifest() {
const dirOf = (u) => { try { return new URL(u, location.href).href.replace(/[^/]*$/, '') } catch { return '' } }
const rootUrl = this.resolve('manifest.json')
const root = { url: rootUrl, root: dirOf(rootUrl) }
const copy = { url: this.resolve('doxa-maps-spa/manifest.json'), root: root.root }
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_TREE_NAME__ + '/manifest.json'
if (!candidates.some((c) => c.url === devFallback)) candidates.push({ url: devFallback, root: dirOf(devFallback) })
const tried = []
const attempt = (i) => {
if (i >= candidates.length) {
this.manifestError = 'manifest.json not found (tried ' + tried.join(', ') + ')'
console.error('[posterverse] ' + this.manifestError + ' — every tab is composed from it.')
return
}
const { url, root: treeRoot } = candidates[i]
tried.push(url)
return fetch(url, { credentials: 'same-origin' })
.then((r) => { if (!r || !r.ok) throw new Error(url + ' → HTTP ' + (r ? r.status : '?')); return r.json() })
.then((m) => {
if (!m) throw new Error(url + ' → empty')
this.manifestError = ''
this.manifestBase = treeRoot || ''
this.applyManifest(m)
})
.catch(() => attempt(i + 1))
}
try { attempt(0) } catch { /* no fetch (non-browser): fail soft */ }
},
// Replace the provisional rail with the rows this tree actually ships, keep the active row by id,
// and re-drive the mount that could not run before its scriptUrl existed.
applyManifest(m) {
this.runtime = (m && m.runtime && typeof m.runtime === 'object') ? m.runtime : {}
this.applyPageGlobals()
const activeId = this.activeKey
this.tabs = resolveTabs(m, (msg) => console.warn(msg)).map(freshTab)
const want = this.requestedTab()
const w = !this._honouredTab && want ? this.tabs.findIndex((t) => t.id === want) : -1
const i = this.tabs.findIndex((t) => t.id === activeId)
this._honouredTab = true
this.select(w >= 0 ? w : (i >= 0 ? i : 0))
},
onSelect(key) {
const i = this.tabs.findIndex((t) => t.id === key)
if (i !== -1) this.select(i)
},
// A tree-relative ref resolves against the manifest's own directory (then the tree base, then '../').
// An absolute or rooted ref is returned untouched: prefixing a rooted dev ref makes a path that exists nowhere.
resolve(ref) {
const raw = String(ref)
if (/^(https?:)?\/\//.test(raw) || raw.startsWith('/')) return raw
const r = raw.replace(/^\.\//, '')
if (this.manifestBase) return this.manifestBase.replace(/\/+$/, '') + '/' + r
if (this.base) return this.base.replace(/\/+$/, '') + '/' + r
return '../' + r
},
select(i) {
const prevIndex = this.activeIndex
this.activeIndex = i
// Release the outgoing heavy pane first, so at most one live map context exists at a time.
if (prevIndex !== i && prevIndex >= 0) {
const prev = this.tabs[prevIndex]
if (prev && prev.heavy && prev.mounted) this.releaseTab(prevIndex, prev)
}
const t = this.tabs[i]
if (!t) return
// Tell the host which plugin is showing (CustomEvent 'tab-change', detail[0] = { id, label }).
if (prevIndex !== i || !this._announced) { this._announced = true; this.$emit('tab-change', { id: t.id, label: this.tabLabel(t) }) }
if (!t.scriptUrl) return // applyManifest selects again once the manifest lands
// A heavy tab's slot exists only after its v-if subtree renders, so mount on the next tick.
if (!t.mounted) this.$nextTick(() => { if (this.activeIndex === i) this.mountElement(i) })
},
releaseTab(i, t) {
try { const slot = this._slots && this._slots[i]; if (slot) slot.innerHTML = '' } catch { /* reset either way */ }
if (this._slots) delete this._slots[i]
t.mounted = false; t.state = 'idle'; t.error = ''
},
// THE ONE profile-config every nested element receives (the Research Gallery's contract).
childConfig(t) {
const pc = { profile: t.profile || t.id, instanceId: 'pv-' + t.id }
if (this.effectiveLocale) pc.locale = this.effectiveLocale
const tk = this.tk || this.cfg.tk || this.runtime.token
if (tk) pc.tk = tk
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
if (this.effectiveProvider) pc.provider = this.effectiveProvider
if (this.base) pc.treeBase = this.base
if (this.assetsBase) pc.assetsBase = this.assetsBase
// The host owns its URL, hash and title: a nested plugin must not write them.
pc.urlSync = false
// The plugin's own name for its print header: this tab's localized label.
pc.mapTitle = this.tabLabel(t)
return pc
},
// Page globals a generated leaf page bakes and a foreign host does not. Set ONLY when the page has
// not set them: the host's own value always wins. MAP_APP_API_URL has no profile-config path, and
// a composite's inner maps read MAP_TOKEN / MAP_PROVIDER rather than their parent's config.
applyPageGlobals() {
if (typeof window === 'undefined') return
const api = String(this.apiBaseUrl || this.cfg.apiBaseUrl || this.runtime.apiBaseUrl || '').trim()
if (api && !window.MAP_APP_API_URL) window.MAP_APP_API_URL = api.replace(/\/+$/, '')
const tk = this.tk || this.cfg.tk || this.runtime.token
if (tk && !window.MAP_TOKEN) window.MAP_TOKEN = tk
if (this.effectiveProvider && !window.MAP_PROVIDER) window.MAP_PROVIDER = this.effectiveProvider
},
// Inject the bundle's script once, wait for its TAG to register (onload fires even when a dev module
// fails to execute), then create the element in the pane's empty slot.
mountElement(i) {
const t = this.tabs[i]
t.mounted = true
t.state = 'loading'
loadScriptOnce(this.resolve(t.scriptUrl)).then(() => whenTagDefined(t.tag)).then(() => {
try {
// Deactivated (and released) while its script loaded: mounting now would leak a map context.
if (t.heavy && this.activeIndex !== i) { t.mounted = false; t.state = 'idle'; return }
const slot = this._slots && this._slots[i]
if (!slot) { t.state = 'error'; t.error = 'no slot'; return }
slot.innerHTML = ''
const el = document.createElement(t.tag)
el.setAttribute('profile-config', JSON.stringify(this.childConfig(t)))
// Also as a plain attribute: an element without a ProfileLoader reads tree-base as a prop.
if (this.base) el.setAttribute('tree-base', this.base.replace(/\/+$/, ''))
// Pin the element to the pane. min-height:0 is load-bearing: a child's own floor (420-600px on
// these plugins) otherwise wins over height:100% and the pane scrolls the whole plugin.
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;min-height:0;'
slot.appendChild(el)
t.state = 'ready'
} catch (e) {
t.state = 'error'
t.error = 'mount failed: ' + ((e && e.message) || String(e))
}
}).catch((e) => {
t.state = 'error'
t.error = (e && e.message) || String(e)
})
},
},
})
</script>
<style>
/* The element is its own containing block with a real height, so the layout (.vtl, position:absolute)
stays inside the box the host placed it in. A host's own height or --posterverse-height overrides it. */
:host { display: block; position: relative; height: var(--posterverse-height, 85vh); min-height: 480px; }
/* Shadow DOM isolates selectors, not inheritance: reset the host page's typography at the box. */
:host {
font-size: 16px; line-height: normal; font-weight: 400; font-style: normal; font-variant: normal;
font-stretch: normal; letter-spacing: normal; word-spacing: normal; text-transform: none;
text-align: start; text-indent: 0; text-shadow: none; white-space: normal; hyphens: manual;
visibility: visible; cursor: auto;
}
:host([hidden]) { display: none; }
/* The pane fills the layout's content area and owns the scroll. --profile-min-h:0 opts every child out of
ProfileLoader's 100dvh floor, which measures the viewport while this pane measures its host. */
.pv-pane { position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; --profile-min-h: 0; }
.pv-slot { position: relative; min-height: 100%; }
.pv-hint {
position: absolute; inset: 0; display: grid; place-items: center; text-align: center;
color: #6d766e; font-size: 13px; padding: 20px;
}
.pv-hint.err { color: #b3261e; }
.pv-hint small { color: #6d766e; }
</style>

View file

@ -0,0 +1,155 @@
#!/usr/bin/env node
/**
* check-posterverse-shelf.mjs — Posterverse shows exactly its allow-list, never a gated plugin, and its
* declared `composes` stays in step with its rail.
*
* WHY. The Research Gallery's Experimental folder is a deny-list catch-all ("every bundle nobody
* placed"). Posterverse deliberately is not: copied here, that rule would expose key-cities, which is
* GATED. The allow-list lives in src/tabs.js; this gate is what keeps it one.
*
* WHAT IT ASSERTS (each arm is poisoned by --self-test before a pass is trusted):
* A. the rail's rows are exactly the named sets: Posters = upg-100, country-posters, poster-design;
* Experimental = wall-of-people-groups, all-people-groups, upg-neighbor, bible-posters. Ids unique,
* every row in a declared section.
* B. no row names a NEVER bundle (key-cities, the shells themselves), and resolving the built
* manifest WITH a key-cities bundle planted in it yields no key-cities row (the real manifest
* withholds gated profiles, so without the plant the exclusion would never be exercised).
* C. every non-provisional row resolves on the built manifest to the profile it names (a silent
* fallback to a leaf is the "composite shown as a flat list" defect), and a row whose bundle is
* absent is pruned.
* D. posterverse.meta.json `composes` == the bundle/profile of every row whose bundle exists in
* plugin-designs/ (two-sided: nothing declared that the rail cannot show, nothing shown undeclared).
* E. strings: every locale carries every English key, and every key the shell reads exists in English.
*
* Headless, no browser. Exit 0 pass · 1 fail · 75 not measured (no built manifest).
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const TAG = 'POSTERVERSE-SHELF'
const HERE = dirname(fileURLToPath(import.meta.url))
const PLUGIN = resolve(HERE, '..')
const ROOT = resolve(PLUGIN, '../..')
const EXPECT = {
posters: ['upg-100', 'country-posters', 'poster-design'],
experimental: ['wall-of-people-groups', 'all-people-groups', 'upg-neighbor', 'bible-posters'],
}
const GATED = 'key-cities'
const sameList = (a, b) => a.length === b.length && a.every((x, i) => x === b[i])
export function checkShelf({ SECTIONS, TABS, NEVER, resolveTabs, manifest, meta, bundleExists, STRINGS }) {
const errs = []
const notes = []
// A. exact membership, per section, in order
const ids = TABS.map((t) => t.id)
if (new Set(ids).size !== ids.length) errs.push(`A: duplicate row id in TABS: ${ids.join(', ')}`)
const sectionKeys = new Set(SECTIONS.map((s) => s.key))
for (const t of TABS) if (!sectionKeys.has(t.section)) errs.push(`A: row ${t.id} is in undeclared section "${t.section}"`)
for (const [sec, want] of Object.entries(EXPECT)) {
const got = TABS.filter((t) => t.section === sec).map((t) => t.id)
if (!sameList(got, want)) errs.push(`A: section ${sec} rows are [${got.join(', ')}], expected [${want.join(', ')}]`)
}
const extra = TABS.filter((t) => !EXPECT[t.section]).map((t) => t.id)
if (extra.length) errs.push(`A: rows outside the allow-listed sections: ${extra.join(', ')}`)
// B. never a gated bundle — by declaration and by resolution over the real manifest
if (!NEVER.has(GATED)) errs.push(`B: NEVER no longer lists ${GATED}`)
for (const t of TABS) if (NEVER.has(t.bundle) || t.bundle === GATED) errs.push(`B: row ${t.id} names never-shown bundle ${t.bundle}`)
// The built manifest WITHHOLDS gated profiles (generate-tree), so a real tree never offers key-cities
// to the resolver. Plant it (shaped like a real row) so the exclusion is exercised on every run: the
// question is whether Posterverse would show it if the tree ever did carry it.
const planted = { ...manifest, bundles: [...(manifest.bundles || []).filter((b) => b.bundle !== GATED),
{ bundle: GATED, scriptUrl: `./${GATED}/${GATED}.js`, elements: [`${GATED}-map`], profiles: [{ profile: GATED, element: `${GATED}-map`, isMap: true }] }] }
for (const r of resolveTabs(planted, () => {})) if (r.bundle === GATED || NEVER.has(r.bundle)) errs.push(`B: resolution produced a ${r.bundle} row`)
const resolved = resolveTabs(manifest, () => {})
const inManifest = new Set((manifest.bundles || []).map((b) => b.bundle))
// C. rows resolve to the profile they name; absent bundles are pruned
const resolvedIds = new Set(resolved.map((r) => r.id))
for (const t of TABS) {
const present = inManifest.has(t.bundle)
if (!present && resolvedIds.has(t.id)) errs.push(`C: row ${t.id} resolved although ${t.bundle} is not in the manifest`)
if (present && !t.provisional) {
const r = resolved.find((x) => x.id === t.id)
if (!r) errs.push(`C: row ${t.id} did not resolve although ${t.bundle} is in the manifest`)
else if (r.profile !== t.profile) errs.push(`C: row ${t.id} opens "${r.profile}", not its named profile "${t.profile}"`)
else if (!r.tag || !r.scriptUrl) errs.push(`C: row ${t.id} resolved without a tag or scriptUrl`)
}
}
// D. composes == the rows whose bundle exists in source
const composes = Array.isArray(meta.composes) ? meta.composes : []
const want = TABS.filter((t) => bundleExists(t.bundle, t.profile)).map((t) => `${t.bundle}/${t.profile}`)
for (const c of composes) if (!want.includes(c)) errs.push(`D: meta composes "${c}", which no existing rail row shows`)
for (const w of want) if (!composes.includes(w)) errs.push(`D: the rail shows "${w}" but meta.composes does not declare it`)
if (meta.type !== 'nested-mfe-spa') errs.push(`D: meta type is ${JSON.stringify(meta.type)}, expected "nested-mfe-spa"`)
// E. strings
const en = STRINGS.en || {}
const used = ['lab', 'experimentalSub', 'loading', 'loadFailed', 'manifestHint', 'backToTop',
'toggleList', 'openViews', 'expandList', 'collapseList', 'collapse',
...SECTIONS.map((s) => s.i18n), ...TABS.map((t) => 'tab.' + t.id),
...TABS.filter((t) => t.section !== 'experimental').map((t) => 'sub.' + t.id)]
for (const k of used) if (!(k in en)) errs.push(`E: the shell reads "${k}", which English does not define`)
const locales = Object.keys(STRINGS)
if (locales.length !== 11) errs.push(`E: ${locales.length} locales, expected 11`)
for (const l of locales) for (const k of Object.keys(en)) {
const v = STRINGS[l][k]
if (typeof v !== 'string' || !v.trim()) errs.push(`E: ${l} is missing "${k}"`)
}
return { errs, notes, counts: { rows: TABS.length, resolved: resolved.length, composes: composes.length, locales: locales.length, keys: Object.keys(en).length } }
}
async function load() {
const tabs = await import(pathToFileURL(join(PLUGIN, 'src/tabs.js')).href)
const { STRINGS } = await import(pathToFileURL(join(PLUGIN, 'src/strings.js')).href)
const meta = JSON.parse(readFileSync(join(PLUGIN, 'posterverse.meta.json'), 'utf8'))
const { TREE_NAME } = await import(pathToFileURL(join(ROOT, 'internal/_tree-name.mjs')).href)
const mf = join(ROOT, 'public', TREE_NAME, 'manifest.json')
const manifest = existsSync(mf) ? JSON.parse(readFileSync(mf, 'utf8')) : null
const bundleExists = (b, p) => existsSync(join(ROOT, 'plugin-designs', b, `${p}.vue`))
return { ...tabs, STRINGS, meta, manifest, bundleExists }
}
function selfTest(base) {
const clone = (o) => JSON.parse(JSON.stringify(o))
const cases = [
['A: key-cities added to Experimental', (c) => { c.TABS = [...c.TABS, { id: 'key-cities', section: 'experimental', bundle: 'key-cities', profile: 'key-cities' }] }, /^A: section experimental|^B: row key-cities/],
['A: a row dropped', (c) => { c.TABS = c.TABS.filter((t) => t.id !== 'upg-neighbor') }, /^A: section experimental/],
['B: resolver that shows whatever the manifest carries', (c) => { c.resolveTabs = (m) => (m.bundles || []).map((b) => ({ id: b.bundle, bundle: b.bundle, profile: b.bundle, tag: b.bundle, scriptUrl: b.scriptUrl })) }, /^B: resolution produced a key-cities row/],
['C: a composite row falls back to a leaf', (c) => { c.manifest = clone(c.manifest); const b = c.manifest.bundles.find((x) => x.bundle === 'my-upg-100-list'); b.profiles = b.profiles.filter((p) => p.profile !== 'upg-100-mfe') }, /^C: row upg-100 opens/],
['D: composes drift', (c) => { c.meta = { ...c.meta, composes: c.meta.composes.slice(1) } }, /^D: the rail shows/],
['D: composes a ref the rail cannot show', (c) => { c.meta = { ...c.meta, composes: [...c.meta.composes, 'key-cities/key-cities'] } }, /^D: meta composes/],
['E: a locale loses a key', (c) => { c.STRINGS = clone(c.STRINGS); delete c.STRINGS.ar['tab.upg-100'] }, /^E: ar is missing "tab.upg-100"/],
['E: a row without an English label', (c) => { c.STRINGS = clone(c.STRINGS); delete c.STRINGS.en['tab.bible-posters'] }, /^E: the shell reads "tab.bible-posters"/],
]
let bad = 0
for (const [name, mutate, expect] of cases) {
const c = { ...base, TABS: [...base.TABS] }
mutate(c)
const { errs } = checkShelf(c)
const hit = errs.some((e) => expect.test(e))
console.log(`${hit ? 'ok ' : 'MISS'} self-test: ${name}${hit ? '' : ' — errors were: ' + (errs.join(' | ') || 'none')}`)
if (!hit) bad++
}
const clean = checkShelf(base).errs
if (clean.length) { console.log(`MISS self-test: the unmutated inputs are not clean: ${clean.join(' | ')}`); bad++ }
return bad
}
const base = await load()
if (!base.manifest) {
console.log(`${TAG}: NOT MEASURED — no built manifest (run bun run tree first)`)
process.exitCode = 75
} else if (process.argv.includes('--self-test')) {
const bad = selfTest(base)
console.log(bad ? `${TAG} self-test: FAIL (${bad} arm(s) could not fail)` : `${TAG} self-test: PASS (every arm fails on its planted defect)`)
process.exitCode = bad ? 1 : 0
} else {
const { errs, notes, counts } = checkShelf(base)
for (const n of notes) console.log(n)
for (const e of errs) console.log('FAIL ' + e)
const summary = `${counts.rows} rows declared, ${counts.resolved} resolved on the built manifest, ${counts.composes} composes refs, ${counts.keys} keys x ${counts.locales} locales`
if (errs.length) { console.log(`${TAG}: FAIL (${errs.length}) — ${summary}`); process.exitCode = 1 }
else if (notes.length) { console.log(`${TAG}: NOT MEASURED — ${summary}`); process.exitCode = 75 }
else console.log(`${TAG}: PASS — ${summary}`)
}

View file

@ -0,0 +1,169 @@
/**
* strings.js — Posterverse's own words, in the 11 shipped locales.
*
* PRIVATE TO THIS PLUGIN, like the Research Gallery's table: these strings belong to the shell alone
* (its rail, sections and status hints). Each nested poster plugin translates ITSELF.
*
* VOCABULARY FOLLOWS THE RESEARCH GALLERY. A row that also appears in the gallery
* (plugin-designs/doxa-maps-spa/src/strings.js) uses the gallery's words verbatim, so the same plugin
* has one name in both shells: tab.upg-100 follows the "UPG 100 List Posters" rename, and the
* Experimental names, status hints and the layout's own controls are copied as the gallery ships them.
*
* `en` is the source and the fallback: a key missing in a locale renders in English rather than as a
* key name. {label} is the only placeholder.
*/
export const STRINGS = {
en: {
'sec.posters': 'Posters', 'sec.experimental': 'Experimental',
'tab.upg-100': 'UPG 100 List Posters', 'sub.upg-100': 'my unreached list + posters',
'tab.country-posters': 'Country Posters', 'sub.country-posters': 'a poster for every country',
'tab.poster-design': 'Poster Design', 'sub.poster-design': 'every poster style and size',
'tab.wall-of-people-groups': 'Wall of People Groups', 'tab.all-people-groups': 'All People Groups',
'tab.upg-neighbor': 'UPG Neighbor', 'tab.bible-posters': 'Bible Posters',
experimentalSub: 'experimental bundle', lab: 'lab',
loading: 'Loading {label}…', loadFailed: 'Could not load {label}.',
manifestHint: 'This tab is composed from manifest.json — build the tree, or serve the built tree, to load it.',
backToTop: 'Back to top',
toggleList: 'Toggle list', openViews: 'Open the list of views', expandList: 'Expand list', collapseList: 'Collapse list', collapse: 'Collapse',
},
es: {
'sec.posters': 'Carteles', 'sec.experimental': 'Experimental',
'tab.upg-100': 'Carteles de la lista UPG 100', 'sub.upg-100': 'mi lista de no alcanzados + carteles',
'tab.country-posters': 'Carteles por país', 'sub.country-posters': 'un cartel para cada país',
'tab.poster-design': 'Diseño de carteles', 'sub.poster-design': 'todos los estilos y tamaños de cartel',
'tab.wall-of-people-groups': 'Muro de los pueblos', 'tab.all-people-groups': 'Todos los pueblos',
'tab.upg-neighbor': 'Vecino UPG', 'tab.bible-posters': 'Carteles bíblicos',
experimentalSub: 'mapa experimental', lab: 'beta',
loading: 'Cargando {label}…', loadFailed: 'No se pudo cargar {label}.',
manifestHint: 'Esta pestaña se compone a partir de manifest.json: compila el árbol o sirve el árbol compilado para cargarla.',
backToTop: 'Volver arriba',
toggleList: 'Mostrar u ocultar la lista', openViews: 'Abrir la lista de vistas', expandList: 'Expandir la lista', collapseList: 'Contraer la lista', collapse: 'Contraer',
},
fr: {
'sec.posters': 'Affiches', 'sec.experimental': 'Expérimental',
'tab.upg-100': 'Affiches de la liste UPG 100', 'sub.upg-100': 'ma liste des non atteints + affiches',
'tab.country-posters': 'Affiches par pays', 'sub.country-posters': 'une affiche pour chaque pays',
'tab.poster-design': 'Conception d’affiches', 'sub.poster-design': 'tous les styles et formats d’affiche',
'tab.wall-of-people-groups': 'Mur des peuples', 'tab.all-people-groups': 'Tous les peuples',
'tab.upg-neighbor': 'Voisin UPG', 'tab.bible-posters': 'Affiches bibliques',
experimentalSub: 'carte expérimentale', lab: 'bêta',
loading: 'Chargement de {label}…', loadFailed: 'Impossible de charger {label}.',
manifestHint: "Cet onglet est composé à partir de manifest.json : construisez l'arborescence ou servez l'arborescence construite pour le charger.",
backToTop: 'Retour en haut',
toggleList: 'Afficher ou masquer la liste', openViews: 'Ouvrir la liste des vues', expandList: 'Déplier la liste', collapseList: 'Replier la liste', collapse: 'Replier',
},
de: {
'sec.posters': 'Poster', 'sec.experimental': 'Experimentell',
'tab.upg-100': 'Poster der UPG 100 Liste', 'sub.upg-100': 'meine Liste der Unerreichten + Poster',
'tab.country-posters': 'Länderposter', 'sub.country-posters': 'ein Poster für jedes Land',
'tab.poster-design': 'Postergestaltung', 'sub.poster-design': 'alle Posterstile und -größen',
'tab.wall-of-people-groups': 'Wand der Volksgruppen', 'tab.all-people-groups': 'Alle Volksgruppen',
'tab.upg-neighbor': 'UPG-Nachbar', 'tab.bible-posters': 'Bibelposter',
experimentalSub: 'experimentelle Karte', lab: 'Beta',
loading: '{label} wird geladen…', loadFailed: '{label} konnte nicht geladen werden.',
manifestHint: 'Dieser Tab wird aus manifest.json zusammengesetzt – bauen Sie den Baum oder stellen Sie den gebauten Baum bereit, um ihn zu laden.',
backToTop: 'Nach oben',
toggleList: 'Liste ein-/ausblenden', openViews: 'Ansichtsliste öffnen', expandList: 'Liste ausklappen', collapseList: 'Liste einklappen', collapse: 'Einklappen',
},
pt: {
'sec.posters': 'Cartazes', 'sec.experimental': 'Experimental',
'tab.upg-100': 'Cartazes da lista UPG 100', 'sub.upg-100': 'minha lista de não alcançados + cartazes',
'tab.country-posters': 'Cartazes por país', 'sub.country-posters': 'um cartaz para cada país',
'tab.poster-design': 'Design de cartazes', 'sub.poster-design': 'todos os estilos e tamanhos de cartaz',
'tab.wall-of-people-groups': 'Mural dos povos', 'tab.all-people-groups': 'Todos os povos',
'tab.upg-neighbor': 'Vizinho UPG', 'tab.bible-posters': 'Cartazes bíblicos',
experimentalSub: 'mapa experimental', lab: 'beta',
loading: 'Carregando {label}…', loadFailed: 'Não foi possível carregar {label}.',
manifestHint: 'Esta aba é composta a partir do manifest.json — compile a árvore ou sirva a árvore compilada para carregá-la.',
backToTop: 'Voltar ao topo',
toggleList: 'Mostrar ou ocultar a lista', openViews: 'Abrir a lista de visualizações', expandList: 'Expandir a lista', collapseList: 'Recolher a lista', collapse: 'Recolher',
},
it: {
'sec.posters': 'Poster', 'sec.experimental': 'Sperimentale',
'tab.upg-100': 'Poster della lista UPG 100', 'sub.upg-100': 'la mia lista dei non raggiunti + poster',
'tab.country-posters': 'Poster per paese', 'sub.country-posters': 'un poster per ogni paese',
'tab.poster-design': 'Design dei poster', 'sub.poster-design': 'tutti gli stili e i formati dei poster',
'tab.wall-of-people-groups': 'Muro dei popoli', 'tab.all-people-groups': 'Tutti i popoli',
'tab.upg-neighbor': 'Vicino UPG', 'tab.bible-posters': 'Poster biblici',
experimentalSub: 'mappa sperimentale', lab: 'beta',
loading: 'Caricamento di {label}…', loadFailed: 'Impossibile caricare {label}.',
manifestHint: "Questa scheda è composta da manifest.json: compila l'albero o pubblica l'albero compilato per caricarla.",
backToTop: 'Torna su',
toggleList: 'Mostra o nascondi l’elenco', openViews: 'Apri l’elenco delle viste', expandList: 'Espandi l’elenco', collapseList: 'Comprimi l’elenco', collapse: 'Comprimi',
},
ro: {
'sec.posters': 'Afișe', 'sec.experimental': 'Experimental',
'tab.upg-100': 'Afișe din lista UPG 100', 'sub.upg-100': 'lista mea de neatinși + afișe',
'tab.country-posters': 'Afișe pe țări', 'sub.country-posters': 'un afiș pentru fiecare țară',
'tab.poster-design': 'Design de afișe', 'sub.poster-design': 'toate stilurile și dimensiunile de afiș',
'tab.wall-of-people-groups': 'Zidul popoarelor', 'tab.all-people-groups': 'Toate popoarele',
'tab.upg-neighbor': 'Vecin UPG', 'tab.bible-posters': 'Afișe biblice',
experimentalSub: 'hartă experimentală', lab: 'beta',
loading: 'Se încarcă {label}…', loadFailed: '{label} nu a putut fi încărcat.',
manifestHint: 'Această filă este compusă din manifest.json — construiți arborele sau serviți arborele construit pentru a o încărca.',
backToTop: 'Sus',
toggleList: 'Afișează sau ascunde lista', openViews: 'Deschide lista de vizualizări', expandList: 'Extinde lista', collapseList: 'Restrânge lista', collapse: 'Restrânge',
},
ru: {
'sec.posters': 'Плакаты', 'sec.experimental': 'Экспериментальное',
'tab.upg-100': 'Плакаты списка UPG 100', 'sub.upg-100': 'мой список недостигнутых + плакаты',
'tab.country-posters': 'Плакаты по странам', 'sub.country-posters': 'плакат для каждой страны',
'tab.poster-design': 'Дизайн плакатов', 'sub.poster-design': 'все стили и размеры плакатов',
'tab.wall-of-people-groups': 'Стена народов', 'tab.all-people-groups': 'Все народы',
'tab.upg-neighbor': 'Сосед UPG', 'tab.bible-posters': 'Библейские плакаты',
experimentalSub: 'экспериментальная карта', lab: 'бета',
loading: 'Загрузка: {label}…', loadFailed: 'Не удалось загрузить: {label}.',
manifestHint: 'Эта вкладка собирается из manifest.json — соберите дерево или разместите собранное дерево, чтобы загрузить её.',
backToTop: 'Наверх',
toggleList: 'Показать или скрыть список', openViews: 'Открыть список представлений', expandList: 'Развернуть список', collapseList: 'Свернуть список', collapse: 'Свернуть',
},
ar: {
'sec.posters': 'ملصقات', 'sec.experimental': 'تجريبي',
'tab.upg-100': 'ملصقات قائمة UPG 100', 'sub.upg-100': 'قائمتي لغير المُبشَّرين + ملصقات',
'tab.country-posters': 'ملصقات الدول', 'sub.country-posters': 'ملصق لكل دولة',
'tab.poster-design': 'تصميم الملصقات', 'sub.poster-design': 'كل أنماط الملصقات وأحجامها',
'tab.wall-of-people-groups': 'جدار الشعوب', 'tab.all-people-groups': 'جميع الشعوب',
'tab.upg-neighbor': 'جار UPG', 'tab.bible-posters': 'ملصقات الكتاب المقدس',
experimentalSub: 'خريطة تجريبية', lab: 'تجريبي',
loading: 'جارٍ تحميل {label}…', loadFailed: 'تعذّر تحميل {label}.',
manifestHint: 'تُبنى علامة التبويب هذه من manifest.json — ابنِ الشجرة أو قدّم الشجرة المبنية لتحميلها.',
backToTop: 'إلى الأعلى',
toggleList: 'إظهار القائمة أو إخفاؤها', openViews: 'فتح قائمة العروض', expandList: 'توسيع القائمة', collapseList: 'طي القائمة', collapse: 'طي',
},
hi: {
'sec.posters': 'पोस्टर', 'sec.experimental': 'प्रायोगिक',
'tab.upg-100': 'UPG 100 सूची पोस्टर', 'sub.upg-100': 'मेरी अप्राप्त सूची + पोस्टर',
'tab.country-posters': 'देश पोस्टर', 'sub.country-posters': 'हर देश के लिए एक पोस्टर',
'tab.poster-design': 'पोस्टर डिज़ाइन', 'sub.poster-design': 'हर पोस्टर शैली और आकार',
'tab.wall-of-people-groups': 'जनसमूहों की दीवार', 'tab.all-people-groups': 'सभी जनसमूह',
'tab.upg-neighbor': 'UPG पड़ोसी', 'tab.bible-posters': 'बाइबल पोस्टर',
experimentalSub: 'प्रायोगिक मानचित्र', lab: 'बीटा',
loading: '{label} लोड हो रहा है…', loadFailed: '{label} लोड नहीं हो सका।',
manifestHint: 'यह टैब manifest.json से बनता है — इसे लोड करने के लिए ट्री बनाएँ या बना हुआ ट्री सर्व करें।',
backToTop: 'ऊपर जाएँ',
toggleList: 'सूची दिखाएँ या छिपाएँ', openViews: 'दृश्यों की सूची खोलें', expandList: 'सूची फैलाएँ', collapseList: 'सूची संक्षिप्त करें', collapse: 'संक्षिप्त करें',
},
zh: {
'sec.posters': '海报', 'sec.experimental': '实验性',
'tab.upg-100': 'UPG 100 名单海报', 'sub.upg-100': '我的未得之民名单 + 海报',
'tab.country-posters': '国家海报', 'sub.country-posters': '每个国家一张海报',
'tab.poster-design': '海报设计', 'sub.poster-design': '所有海报样式与尺寸',
'tab.wall-of-people-groups': '民族之墙', 'tab.all-people-groups': '所有民族',
'tab.upg-neighbor': 'UPG 邻居', 'tab.bible-posters': '圣经海报',
experimentalSub: '实验性地图', lab: '测试版',
loading: '正在加载{label}…', loadFailed: '无法加载{label}。',
manifestHint: '此标签页由 manifest.json 组成——请构建目录树或提供已构建的目录树以加载它。',
backToTop: '回到顶部',
toggleList: '显示或隐藏列表', openViews: '打开视图列表', expandList: '展开列表', collapseList: '折叠列表', collapse: '折叠',
},
}
export const LOCALES = Object.keys(STRINGS)
/** Translate `key` into `locale`, falling back to English, then to the key itself. */
export function translate(locale, key, params) {
const table = STRINGS[locale] || STRINGS.en
let s = table[key] ?? STRINGS.en[key] ?? key
if (params) for (const k in params) s = s.split('{' + k + '}').join(String(params[k]))
return s
}

View file

@ -0,0 +1,87 @@
/**
* tabs.js — WHICH plugins Posterverse shows, and how each row is resolved from the tree's manifest.
*
* Pure data + pure functions (no Vue), so the shell and its headless check read the same list.
*
* ★ THE WHOLE RAIL IS AN ALLOW-LIST, the Experimental folder included. The Research Gallery fills its
* Experimental folder with every bundle nobody placed (a deny-list catch-all). Copied here, that rule
* would show every research map, including key-cities, which is GATED and must never be exposed.
* So every row Posterverse can ever paint is named below, and a bundle that is not named never
* appears, whatever the manifest holds.
*
* ★ A ROW WHOSE BUNDLE IS NOT IN THE TREE IS PRUNED, not painted dead. That is how a row can be listed
* before its plugin lands: Poster Design and Bible Posters are declared with PROVISIONAL ids and stay
* invisible until a bundle of that id exists. Their owners confirm the ids when they start; change the
* `bundle`/`profile` here to match, never the row `id` (the id is the stable key for ?tab= links).
*
* ★ MOUNT THE COMPOSITE, NOT A LEAF. The profile named here is the one that mounts. my-upg-100-list and
* country-posters each ship a leaf map, a leaf wall and the capability-4 composite of the two; the
* composite is the plugin as its owner designed it (and it is what makes Posterverse capability 5).
*/
// The rail's sections, in order. `folder` renders collapsible and starts closed, like the gallery's.
export const SECTIONS = [
{ key: 'posters', i18n: 'sec.posters', icon: 'poster' },
{ key: 'experimental', i18n: 'sec.experimental', icon: 'flask', folder: true, open: false },
]
// Every row Posterverse can show, in rail order.
export const TABS = [
{ id: 'upg-100', section: 'posters', icon: 'list', bundle: 'my-upg-100-list', profile: 'upg-100-mfe' },
{ id: 'country-posters', section: 'posters', icon: 'map', bundle: 'country-posters', profile: 'country-posters-mfe' },
// Provisional ids: no poster-design bundle exists yet, so this row is pruned until one does.
{ id: 'poster-design', section: 'posters', icon: 'poster', bundle: 'poster-design', profile: 'poster-design-mfe', provisional: true },
{ id: 'wall-of-people-groups', section: 'experimental', icon: 'flask', bundle: 'wall-of-people-groups', profile: 'wall-of-people-groups' },
{ id: 'all-people-groups', section: 'experimental', icon: 'flask', bundle: 'all-people-groups', profile: 'all-people-groups' },
{ id: 'upg-neighbor', section: 'experimental', icon: 'flask', bundle: 'upg-neighbor', profile: 'upg-neighbor' },
// Provisional ids: no bible-posters bundle exists yet, so this row is pruned until one does.
{ id: 'bible-posters', section: 'experimental', icon: 'flask', bundle: 'bible-posters', profile: 'bible-posters', provisional: true },
]
// Bundles that must never be a Posterverse row. The allow-list above already excludes them; this set
// makes the exclusion an ASSERTION (check-posterverse-shelf.mjs fails if a row ever names one), so a
// later edit cannot add one by accident.
export const NEVER = new Set(['key-cities', 'posterverse', 'doxa-maps-spa', 'plugin-studio'])
// Elements that own or NEST a WebGL map, so their pane is DESTROYED on deactivate rather than hidden
// (browsers keep ~8-16 live contexts and silently kill the oldest). Taken from the Research Gallery's
// measured HEAVY_ELEMENTS for the tags this rail shows; any profile the manifest marks `isMap` is
// treated as heavy too, so a new map row is bounded without an edit here.
const HEAVY_TAGS = new Set(['my-upg-100-list-map', 'country-posters-map', 'all-people-groups'])
/**
* Complete each row from the manifest: the bundle's scriptUrl and the profile's element tag. A row
* whose bundle is absent from this tree, or that ships no script, is left out (pruned).
* Returns the resolved rows in rail order.
*/
export function resolveTabs(manifest, warn = () => {}) {
const bundles = (manifest && Array.isArray(manifest.bundles)) ? manifest.bundles : []
const byBundle = Object.fromEntries(bundles.map((b) => [b && b.bundle, b]))
const out = []
for (const d of TABS) {
if (NEVER.has(d.bundle)) continue
const b = byBundle[d.bundle]
if (!b) continue
const profiles = Array.isArray(b.profiles) ? b.profiles : []
let p = profiles.find((x) => x && x.profile === d.profile)
if (!p) {
// The named profile is gone (renamed, or a provisional id guessed wrong). Degrade to the profile
// named like the bundle, then the first, and say so: a quiet fallback is how a composite row
// once showed a bare leaf.
p = profiles.find((x) => x && x.profile === d.bundle) || profiles[0]
if (!p) continue
warn(`[posterverse] ${d.bundle} has no profile "${d.profile}"; row "${d.id}" opens "${p.profile}" instead`)
}
const tag = p.element || (Array.isArray(b.elements) && b.elements[0]) || d.bundle
const scriptUrl = b.scriptUrl || p.scriptUrl || ''
if (!scriptUrl) continue
out.push({
...d,
tag,
scriptUrl,
profile: p.profile,
heavy: HEAVY_TAGS.has(tag) || !!p.isMap,
})
}
return out
}

View file

@ -2,7 +2,7 @@
This folder is GENERATED by `bun run build`; do not edit it by hand. It holds exactly one thing:
- **`dx-maps/`** — the output folder: 25 plugin bundles and 37 pages, self-contained.
- **`dx-maps/`** — the output folder: 26 plugin bundles and 38 pages, self-contained.
Copy this one folder to any web host (or open it from disk) and it works.
- `dx-maps/index.html` — the front page: the Plugin Studio, every plugin in one place.
- `dx-maps/<plugin>/<plugin>.js` — a plugin's bundle (a custom element; load it with a `<script>` tag).

File diff suppressed because one or more lines are too long

View file

@ -81,7 +81,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../../dashboard-plus-grid.js" integrity="sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -81,7 +81,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../dashboard-plus-grid.js" integrity="sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 25,
"profileCount": 37,
"bundleCount": 26,
"profileCount": 38,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life"
},
@ -266,7 +266,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em",
"integrity": "sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI",
"elements": [
"dashboard-plus-grid"
],
@ -290,8 +290,8 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -881,7 +881,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1",
"integrity": "sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB",
"elements": [
"my-upg-100-list-map"
],
@ -904,7 +904,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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"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-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -920,8 +920,8 @@
"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": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<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-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<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-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -937,8 +937,8 @@
"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": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<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-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<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-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]
@ -1065,6 +1065,59 @@
}
]
},
{
"bundle": "posterverse",
"scriptUrl": "./posterverse/posterverse.js",
"output": "dx-maps/posterverse/posterverse.js",
"integrity": "sha384-xwStH6d1rIR+LOJ9iBnJXyNFIMTzSQ51YA2+UhqJgloN4EeH6vYubtHY3JM9j7p0",
"elements": [
"posterverse-app"
],
"composes": [
"my-upg-100-list/upg-100-mfe",
"country-posters/country-posters-mfe",
"wall-of-people-groups/wall-of-people-groups",
"all-people-groups/all-people-groups",
"upg-neighbor/upg-neighbor"
],
"stage": "experimental",
"profiles": [
{
"profile": "posterverse",
"stage": "experimental",
"label": "Posterverse (vertical-tab app shell: every poster plugin)",
"file": "posterverse.vue",
"description": "Every poster plugin in one app: a vertical-tab rail of real, embeddable poster plugins.",
"element": "posterverse-app",
"type": "nested-mfe-spa",
"isMap": false,
"iframeUrl": "./posterverse/posterverse/index.html",
"scriptUrl": "./posterverse/posterverse.js",
"embed": {
"iframe": "<iframe src=\"./posterverse/posterverse/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=\"./posterverse/posterverse.js\" integrity=\"sha384-xwStH6d1rIR+LOJ9iBnJXyNFIMTzSQ51YA2+UhqJgloN4EeH6vYubtHY3JM9j7p0\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
},
"instances": [
{
"id": "app",
"title": "Posterverse — every poster plugin (app shell)",
"apiBaseUrl": "",
"props": {
"profile": "posterverse",
"provider": "maplibre",
"instanceId": "posterverse-app",
"heading": "Posterverse"
},
"element": "posterverse-app",
"iframeUrl": "./posterverse/posterverse/app/index.html",
"embed": {
"iframe": "<iframe src=\"./posterverse/posterverse/app/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>"
}
}
]
}
]
},
{
"bundle": "pplr-data-maps",
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
@ -1193,7 +1246,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "dx-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0",
"integrity": "sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/",
"elements": [
"research-dashboard-map"
],
@ -1212,8 +1265,8 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 25,
"profileCount": 37,
"bundleCount": 26,
"profileCount": 38,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life"
},
@ -266,7 +266,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em",
"integrity": "sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI",
"elements": [
"dashboard-plus-grid"
],
@ -290,8 +290,8 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -881,7 +881,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1",
"integrity": "sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB",
"elements": [
"my-upg-100-list-map"
],
@ -904,7 +904,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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"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-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -920,8 +920,8 @@
"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": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<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-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<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-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -937,8 +937,8 @@
"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": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<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-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<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-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]
@ -1065,6 +1065,59 @@
}
]
},
{
"bundle": "posterverse",
"scriptUrl": "./posterverse/posterverse.js",
"output": "dx-maps/posterverse/posterverse.js",
"integrity": "sha384-xwStH6d1rIR+LOJ9iBnJXyNFIMTzSQ51YA2+UhqJgloN4EeH6vYubtHY3JM9j7p0",
"elements": [
"posterverse-app"
],
"composes": [
"my-upg-100-list/upg-100-mfe",
"country-posters/country-posters-mfe",
"wall-of-people-groups/wall-of-people-groups",
"all-people-groups/all-people-groups",
"upg-neighbor/upg-neighbor"
],
"stage": "experimental",
"profiles": [
{
"profile": "posterverse",
"stage": "experimental",
"label": "Posterverse (vertical-tab app shell: every poster plugin)",
"file": "posterverse.vue",
"description": "Every poster plugin in one app: a vertical-tab rail of real, embeddable poster plugins.",
"element": "posterverse-app",
"type": "nested-mfe-spa",
"isMap": false,
"iframeUrl": "./posterverse/posterverse/index.html",
"scriptUrl": "./posterverse/posterverse.js",
"embed": {
"iframe": "<iframe src=\"./posterverse/posterverse/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=\"./posterverse/posterverse.js\" integrity=\"sha384-xwStH6d1rIR+LOJ9iBnJXyNFIMTzSQ51YA2+UhqJgloN4EeH6vYubtHY3JM9j7p0\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
},
"instances": [
{
"id": "app",
"title": "Posterverse — every poster plugin (app shell)",
"apiBaseUrl": "",
"props": {
"profile": "posterverse",
"provider": "maplibre",
"instanceId": "posterverse-app",
"heading": "Posterverse"
},
"element": "posterverse-app",
"iframeUrl": "./posterverse/posterverse/app/index.html",
"embed": {
"iframe": "<iframe src=\"./posterverse/posterverse/app/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>"
}
}
]
}
]
},
{
"bundle": "pplr-data-maps",
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
@ -1193,7 +1246,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "dx-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0",
"integrity": "sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/",
"elements": [
"research-dashboard-map"
],
@ -1212,8 +1265,8 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}

File diff suppressed because one or more lines are too long

View file

@ -67,7 +67,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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -76,7 +76,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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -81,7 +81,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-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB" 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

@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>Posterverse — every poster plugin (app shell) · posterverse</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself.
HEIGHT IN svh, NOT inset:0. A fixed box with inset:0 follows the phone's DYNAMIC viewport, so the map
(and a legend sheet sized from it) changes height every time the address bar hides or shows. 100svh is
the viewport WITH the bar shown and never changes; when the bar hides, the page background shows below
instead of the map resizing. On a desktop svh = the viewport, so nothing moves there. The 100% before it
is the fallback for a browser without svh, which would otherwise size the box to its content.
--profile-min-h:0 opts out of ProfileLoader's 100dvh floor, which would grow the element again. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, posterverse-app { position: fixed; inset: 0 auto auto 0; width: 100%; height: 100%; height: 100svh; --profile-min-h: 0; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }
/* ── PRINT, BOTH LEAF SHAPES ────────────────────────────────────────────────────────────
Two things every generated leaf got wrong on paper, because no generated page has ever
carried an @media print rule (measured: 0 across the tree).
The background is #0a0e15 — a dark page, printed. Browsers drop background colours by
default, but a reader who ticks "Background graphics" to get the MAP also gets the page
flooded black, and that is the reader who most wants the printout.
#status is the LOADING OVERLAY: position:fixed, inset:0, over everything. On paper it is
a full sheet reading "loading…" above the content that did in fact load. It is hidden
only while it is NOT an error — an error still prints, because a blank sheet with the
explanation suppressed is worse than a sheet that says what went wrong. */
@media print {
html, body { height: auto; background: #fff; }
#status:not(.err) { display: none; }
/* ★ RELEASE THE FIXED HOST, MEASURED (rega, RQ-RGPRINT on a035408e2). The leaf pins both the
slot and the element with position:fixed; inset:0. A fixed element is painted once, on the
first page, so the printed document was exactly ONE viewport: 900px tall, while the content
continued to y=1753. Every plugin whose leaf is taller than a screen has been printing its
top screenful and silently dropping the rest — the dashboard printed its stats and its map,
then blank to the bottom of the sheet.
min-height, not a released height alone: a full-bleed map leaf takes its SIZE from this rule,
so height:auto with nothing else can leave the element a zero-height box and print an empty
page — a worse failure than the one being fixed, and the discriminator is not isMap
(region-dashboards is isMap:true and is a scrolling dashboard). The floor keeps a definite
box for the map case while letting a taller document grow onto page two. */
#slot, posterverse-app { position: static; inset: auto; width: 100%; height: auto; min-height: 14cm; }
}
</style>
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <posterverse-app> custom element. -->
<script src="../../posterverse.js" integrity="sha384-xwStH6d1rIR+LOJ9iBnJXyNFIMTzSQ51YA2+UhqJgloN4EeH6vYubtHY3JM9j7p0" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
// 2. ?tk= URL param — token carried in the shared link
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
// *endpoint* is resolved and fetched:
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
// means "read that global"; anything else is used as a URL — so both a global
// name and a plain URL are safe (no SyntaxError footgun).
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"posterverse","provider":"maplibre","instanceId":"posterverse-app","heading":"Posterverse"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
var TOKEN_FREE = true;
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
var NESTS_MAPS = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
function askToken() {
if (!status) return;
status.className = 'err';
status.innerHTML = 'map needs a Mapbox token — ' +
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
var go = document.getElementById('tkGo');
if (go) go.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (!v) return;
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
mount(v);
});
}
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
mounted element does not cover shows it through, because every layer above body is
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
unpainted canvas as first supposed. Clearing it here (the one place we already know the
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
function done() {
if (status && status.parentNode) { status.parentNode.removeChild(status); }
document.documentElement.style.background = '#f3f3f1';
document.body.style.background = '#f3f3f1';
}
function mount(token) {
if (!token && !TOKEN_FREE) { return askToken(); }
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("posterverse-app");
el.setAttribute('profile-config', JSON.stringify(cfg));
slot.innerHTML = '';
slot.appendChild(el);
done();
} catch (e) { fail(e && e.message ? e.message : String(e)); }
}
function qp(name) {
try { return new URLSearchParams(location.search).get(name) || ''; }
catch (e) { return ''; }
}
function extractToken(data) {
if (typeof data === 'string') return data.trim();
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
return '';
}
// Rungs 1-3b: an actual token → mount directly, no fetch.
// 2b = localStorage: the paste-once store shared with the staging pages
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
var stored = '';
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
var urlTk = qp('tk');
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins,
// and 'auto' draws with MapLibre whether or not a token is available (the element decides that).
// This page only decides whether to FIND a token first: when one is on offer (a rung above, a
// host-set window.MAP_TOKEN, or a token endpoint) it resolves it, since place search uses it on
// either engine; with none, it mounts at once, keyless: no token prompt, no request to Mapbox.
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
if (TOKEN_FREE) {
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
mount('');
// A token-free composite with no token anywhere: its content renders, but its nested maps
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
var bar = document.createElement('div');
bar.id = 'status';
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
document.body.appendChild(bar);
status = bar;
askToken();
var goBtn = document.getElementById('tkGo');
if (goBtn) goBtn.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (v) { location.reload(); }
});
}
return;
}
if (directToken) { return mount(directToken); }
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
// A single hard-coded pick used to kill the page when its one url 404'd or was
// blocked by CORS; a shared link must work wherever the tree is hosted.
var candidates = [];
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
addCandidate(window.MAP_TOKEN_URL);
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
addCandidate(DEFAULT_TOKEN_URL);
function fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless.
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
askToken();
if (status) {
var d = document.createElement('div');
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
status.insertBefore(d, status.firstChild);
}
return;
}
var url = candidates[i];
return fetch(url)
.then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) {
var tk = extractToken(data);
if (!tk) throw new Error(url + ' → no token in response');
mount(tk);
})
.catch(function (e) {
errors.push(e && e.message ? e.message : String(e));
return fetchToken(i + 1, errors);
});
}
fetchToken(0, []);
})();
</script>
</body>
</html>

View file

@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>posterverse · posterverse</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself.
HEIGHT IN svh, NOT inset:0. A fixed box with inset:0 follows the phone's DYNAMIC viewport, so the map
(and a legend sheet sized from it) changes height every time the address bar hides or shows. 100svh is
the viewport WITH the bar shown and never changes; when the bar hides, the page background shows below
instead of the map resizing. On a desktop svh = the viewport, so nothing moves there. The 100% before it
is the fallback for a browser without svh, which would otherwise size the box to its content.
--profile-min-h:0 opts out of ProfileLoader's 100dvh floor, which would grow the element again. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, posterverse-app { position: fixed; inset: 0 auto auto 0; width: 100%; height: 100%; height: 100svh; --profile-min-h: 0; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }
/* ── PRINT, BOTH LEAF SHAPES ────────────────────────────────────────────────────────────
Two things every generated leaf got wrong on paper, because no generated page has ever
carried an @media print rule (measured: 0 across the tree).
The background is #0a0e15 — a dark page, printed. Browsers drop background colours by
default, but a reader who ticks "Background graphics" to get the MAP also gets the page
flooded black, and that is the reader who most wants the printout.
#status is the LOADING OVERLAY: position:fixed, inset:0, over everything. On paper it is
a full sheet reading "loading…" above the content that did in fact load. It is hidden
only while it is NOT an error — an error still prints, because a blank sheet with the
explanation suppressed is worse than a sheet that says what went wrong. */
@media print {
html, body { height: auto; background: #fff; }
#status:not(.err) { display: none; }
/* ★ RELEASE THE FIXED HOST, MEASURED (rega, RQ-RGPRINT on a035408e2). The leaf pins both the
slot and the element with position:fixed; inset:0. A fixed element is painted once, on the
first page, so the printed document was exactly ONE viewport: 900px tall, while the content
continued to y=1753. Every plugin whose leaf is taller than a screen has been printing its
top screenful and silently dropping the rest — the dashboard printed its stats and its map,
then blank to the bottom of the sheet.
min-height, not a released height alone: a full-bleed map leaf takes its SIZE from this rule,
so height:auto with nothing else can leave the element a zero-height box and print an empty
page — a worse failure than the one being fixed, and the discriminator is not isMap
(region-dashboards is isMap:true and is a scrolling dashboard). The floor keeps a definite
box for the map case while letting a taller document grow onto page two. */
#slot, posterverse-app { position: static; inset: auto; width: 100%; height: auto; min-height: 14cm; }
}
</style>
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <posterverse-app> custom element. -->
<script src="../posterverse.js" integrity="sha384-xwStH6d1rIR+LOJ9iBnJXyNFIMTzSQ51YA2+UhqJgloN4EeH6vYubtHY3JM9j7p0" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
// 2. ?tk= URL param — token carried in the shared link
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
// *endpoint* is resolved and fetched:
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
// means "read that global"; anything else is used as a URL — so both a global
// name and a plain URL are safe (no SyntaxError footgun).
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"profile":"posterverse","dataSource":"pray-tools","instanceId":"posterverse-posterverse","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
var TOKEN_FREE = true;
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
var NESTS_MAPS = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
function askToken() {
if (!status) return;
status.className = 'err';
status.innerHTML = 'map needs a Mapbox token — ' +
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
var go = document.getElementById('tkGo');
if (go) go.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (!v) return;
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
mount(v);
});
}
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
mounted element does not cover shows it through, because every layer above body is
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
unpainted canvas as first supposed. Clearing it here (the one place we already know the
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
function done() {
if (status && status.parentNode) { status.parentNode.removeChild(status); }
document.documentElement.style.background = '#f3f3f1';
document.body.style.background = '#f3f3f1';
}
function mount(token) {
if (!token && !TOKEN_FREE) { return askToken(); }
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("posterverse-app");
el.setAttribute('profile-config', JSON.stringify(cfg));
slot.innerHTML = '';
slot.appendChild(el);
done();
} catch (e) { fail(e && e.message ? e.message : String(e)); }
}
function qp(name) {
try { return new URLSearchParams(location.search).get(name) || ''; }
catch (e) { return ''; }
}
function extractToken(data) {
if (typeof data === 'string') return data.trim();
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
return '';
}
// Rungs 1-3b: an actual token → mount directly, no fetch.
// 2b = localStorage: the paste-once store shared with the staging pages
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
var stored = '';
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
var urlTk = qp('tk');
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins,
// and 'auto' draws with MapLibre whether or not a token is available (the element decides that).
// This page only decides whether to FIND a token first: when one is on offer (a rung above, a
// host-set window.MAP_TOKEN, or a token endpoint) it resolves it, since place search uses it on
// either engine; with none, it mounts at once, keyless: no token prompt, no request to Mapbox.
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
if (TOKEN_FREE) {
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
mount('');
// A token-free composite with no token anywhere: its content renders, but its nested maps
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
var bar = document.createElement('div');
bar.id = 'status';
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
document.body.appendChild(bar);
status = bar;
askToken();
var goBtn = document.getElementById('tkGo');
if (goBtn) goBtn.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (v) { location.reload(); }
});
}
return;
}
if (directToken) { return mount(directToken); }
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
// A single hard-coded pick used to kill the page when its one url 404'd or was
// blocked by CORS; a shared link must work wherever the tree is hosted.
var candidates = [];
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
addCandidate(window.MAP_TOKEN_URL);
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
addCandidate(DEFAULT_TOKEN_URL);
function fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless.
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
askToken();
if (status) {
var d = document.createElement('div');
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
status.insertBefore(d, status.firstChild);
}
return;
}
var url = candidates[i];
return fetch(url)
.then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) {
var tk = extractToken(data);
if (!tk) throw new Error(url + ' → no token in response');
mount(tk);
})
.catch(function (e) {
errors.push(e && e.message ? e.message : String(e));
return fetchToken(i + 1, errors);
});
}
fetchToken(0, []);
})();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View file

@ -76,7 +76,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <research-dashboard-map> custom element. -->
<script src="../research-dashboard.js" integrity="sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0" crossorigin="anonymous"></script>
<script src="../research-dashboard.js" integrity="sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: