Take main

# Conflicts:
#	public/doxa-maps/index.html
This commit is contained in:
doxa-maps-88655c70 2026-09-25 04:20:24 +00:00
commit 3a748c9ae9
44 changed files with 612 additions and 550 deletions

View file

@ -137,11 +137,17 @@ every build:
A shortcode + block plugin in `deployment/wordpress/`; its contract test passes
(`bun run check:wp-embed`). Proven, not production-ready.
![Method 4 — WordPress / Disciple Tools plugin, proof of concept](documentation/readme-images/deploy4-wordpress.png)
### 5. Desktop app (Tauri) — 🧪 proof of concept
A desktop binary and `.deb` build from `deployment/src-tauri/` (`bun run check:tauri`). Proven,
not production-ready.
![Method 5 — desktop app (Tauri), proof of concept](documentation/readme-images/deploy5-desktop.png)
<sub>WordPress and Tauri marks from [Simple Icons](https://simpleicons.org) (CC0); the trademarks belong to their owners.</sub>
> **Mapbox token:** the dev server bakes one in; built pages take `?tk=pk.…`, a pasted token, or a
> token endpoint. Or skip Mapbox entirely: `provider: "maplibre"` on any instance renders on MapLibre
> with no key. Every command and prerequisite:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 671 KiB

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View file

@ -17,30 +17,30 @@
},
"cap3-monolithic-research": {
"bundle": "doxa-research-map",
"sha256": "69ad4bce104fa445de704e7a308913e4e4a19601d54c4d43e85bcc406197dfdb"
"sha256": "56845dc5bd33198a7dd8fb42895700f21b4bc1c3b7f98f57832e8373c7782a83"
},
"cap4-upg100-list": {
"bundle": "my-upg-100-list",
"sha256": "31c438fa0c17075b3de9ec45ac72ca345d4cd254778ac871b862627bc1063445"
"sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
},
"cap4-upg100-mfe": {
"bundle": "my-upg-100-list",
"sha256": "31c438fa0c17075b3de9ec45ac72ca345d4cd254778ac871b862627bc1063445"
"sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
},
"cap4-upg100-poster-wall": {
"bundle": "my-upg-100-list",
"sha256": "31c438fa0c17075b3de9ec45ac72ca345d4cd254778ac871b862627bc1063445"
"sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
},
"cap5-doxa-maps-spa": {
"bundle": "doxa-maps-spa",
"sha256": "c7b1aad1e3bd4bbe936858f2159c566c1ef2e24fc9636a3446b1e10723d86502"
"sha256": "428e3741bd0bdbbbbd8ddbfca51dd0bacd471d5387c33f5869f2d121bf5d40c1"
},
"cap5-plugin-studio": {
"bundle": "plugin-studio",
"sha256": "c3d32d94d2df407fc35cea91faf0d46670abf52a63d37000486760361a588a5c"
"sha256": "951dcb60f59ce134fca591376cd65f54750bcb1ed50366f569be4cc863af92d2"
},
"deploy2-mobile-upg100": {
"bundle": "my-upg-100-list",
"sha256": "31c438fa0c17075b3de9ec45ac72ca345d4cd254778ac871b862627bc1063445"
"sha256": "efc16067d66481e80d6242beb792ad83c06b6ac71ef978176074fea855ca63c7"
}
}

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
<rect width="1200" height="630" rx="28" fill="#f8fafc"/>
<rect x="1.5" y="1.5" width="1197" height="627" rx="27" fill="none" stroke="#e2e8f0" stroke-width="3"/>
<g transform="translate(110,175) scale(11.5)"><path d="M21.469 6.825c.84 1.537 1.318 3.3 1.318 5.175 0 3.979-2.156 7.456-5.363 9.325l3.295-9.527c.615-1.54.82-2.771.82-3.864 0-.405-.026-.78-.07-1.11m-7.981.105c.647-.03 1.232-.105 1.232-.105.582-.075.514-.93-.067-.899 0 0-1.755.135-2.88.135-1.064 0-2.85-.15-2.85-.15-.585-.03-.661.855-.075.885 0 0 .54.061 1.125.09l1.68 4.605-2.37 7.08L5.354 6.9c.649-.03 1.234-.1 1.234-.1.585-.075.516-.93-.065-.896 0 0-1.746.138-2.874.138-.2 0-.438-.008-.69-.015C4.911 3.15 8.235 1.215 12 1.215c2.809 0 5.365 1.072 7.286 2.833-.046-.003-.091-.009-.141-.009-1.06 0-1.812.923-1.812 1.914 0 .89.513 1.643 1.06 2.531.411.72.89 1.643.89 2.977 0 .915-.354 1.994-.821 3.479l-1.075 3.585-3.9-11.61.001.014zM12 22.784c-1.059 0-2.081-.153-3.048-.437l3.237-9.406 3.315 9.087c.024.053.05.101.078.149-1.12.393-2.325.609-3.582.609M1.211 12c0-1.564.336-3.05.935-4.39L7.29 21.709C3.694 19.96 1.212 16.271 1.211 12M12 0C5.385 0 0 5.385 0 12s5.385 12 12 12 12-5.385 12-12S18.615 0 12 0" fill="#21759B"/></g>
<text x="480" y="250" font-size="44" font-weight="800" fill="#0f172a">WordPress / Disciple Tools</text>
<text x="480" y="315" font-size="27" fill="#475569">A plugin for WordPress sites, including Disciple Tools:</text>
<text x="480" y="355" font-size="27" fill="#475569">a [doxa_map] shortcode and a block editor block.</text>
<rect x="480" y="400" width="360" height="62" rx="31" fill="#fef3c7" stroke="#d97706" stroke-width="2.5"/>
<text x="660" y="441" text-anchor="middle" font-size="26" font-weight="800" fill="#92400e">PROOF OF CONCEPT</text>
<text x="480" y="510" font-size="21" fill="#64748b">Contract-tested and working — not yet production-ready.</text>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
<rect width="1200" height="630" rx="28" fill="#f8fafc"/>
<rect x="1.5" y="1.5" width="1197" height="627" rx="27" fill="none" stroke="#e2e8f0" stroke-width="3"/>
<g transform="translate(110,175) scale(11.5)"><path d="M13.912 0a8.72 8.72 0 0 0-8.308 6.139c1.05-.515 2.18-.845 3.342-.976 2.415-3.363 7.4-3.412 9.88-.097 2.48 3.315 1.025 8.084-2.883 9.45a6.131 6.131 0 0 1-.3 2.762 8.72 8.72 0 0 0 3.01-1.225A8.72 8.72 0 0 0 13.913 0zm.082 6.451a2.284 2.284 0 1 0-.15 4.566 2.284 2.284 0 0 0 .15-4.566zm-5.629.27a8.72 8.72 0 0 0-3.031 1.235 8.72 8.72 0 1 0 13.06 9.9131 10.173 10.174 0 0 1-3.343.965 6.125 6.125 0 1 1-7.028-9.343 6.114 6.115 0 0 1 .342-2.772zm1.713 6.27a2.284 2.284 0 0 0-2.284 2.283 2.284 2.284 0 0 0 2.284 2.284 2.284 2.284 0 0 0 2.284-2.284 2.284 2.284 0 0 0-2.284-2.284z" fill="#24C8DB"/></g>
<text x="480" y="250" font-size="54" font-weight="800" fill="#0f172a">Desktop app (Tauri)</text>
<text x="480" y="315" font-size="27" fill="#475569">The same map tree wrapped as a desktop app:</text>
<text x="480" y="355" font-size="27" fill="#475569">a native binary and a .deb installer.</text>
<rect x="480" y="400" width="360" height="62" rx="31" fill="#fef3c7" stroke="#d97706" stroke-width="2.5"/>
<text x="660" y="441" text-anchor="middle" font-size="26" font-weight="800" fill="#92400e">PROOF OF CONCEPT</text>
<text x="480" y="510" font-size="21" fill="#64748b">Contract-tested and working — not yet production-ready.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -156,7 +156,12 @@ const HEADER_BYTES = 8192 // only scan the top of each file (header l
const TAG_RE = /^\s*\*\s*@([\w-]+)\s+(.+?)\s*$/gm // JSDoc `* @tag value` lines
const DEFINE_RE = /customElements\.define\(\s*['"]([^'"]+)['"]/g // registered element tags
const isBundleDir = (name) => !name.startsWith('_') && !name.startsWith('.') && !DEST_CONFIG.gatedProfiles.includes(name)
// SCAFFOLDS are source DX copies to start a plugin, not plugins: their SOURCE ships, their placeholder
// pages must not be published. Same by-name rule as internal/plugin-gallery/discover.mjs, so the built
// manifest lists exactly what the dev manifest lists (a blank template-map-a/b in the built studio was
// the only difference, found 2026-09-25). Not gatedProfiles: that list is governance (private data).
const SCAFFOLD_BUNDLES = ['template-bundle']
const isBundleDir = (name) => !name.startsWith('_') && !name.startsWith('.') && !DEST_CONFIG.gatedProfiles.includes(name) && !SCAFFOLD_BUNDLES.includes(name)
// ★ CUSTOM-ELEMENT NAME RULE (HTML spec): lowercase, must contain a hyphen, no spaces.
// @element is NOT a comment — the build copies it VERBATIM into manifest.json and consumers feed

View file

@ -29,7 +29,18 @@ import LegendProgressBar from './LegendProgressBar.vue'
// one proven on .dt-scrim / .lt-overlay / .modal-scrim; it is correct-if-enabled, not observed.
import { useViewportScrim } from '../../utils/viewportScrim.js'
const { t, te } = useI18n()
// ★ i18n IS OPTIONAL IN A LIBRARY COMPONENT. useI18n() THROWS on a host that never installed
// vue-i18n, and seven bundles here do not. Unguarded, this legend renders NOTHING on such a host —
// the failure that blanked people-groups-grid via library FilterDropdown (fixed in 6603dc17).
// Same shape of fix as PinHint.vue and PosterWall.vue.
// The fallback humanizes the key's last segment, so a bare host sees readable English
// ("legend.columns.name" -> "Name") rather than a raw key. `te` answers false so every caller
// falls through to its own default — which is what the defaultTab and columnLabel paths expect.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
let t = (k) => humanizeKey(k)
let te = () => false
try { const i18n = useI18n(); t = i18n.t; te = i18n.te } catch (_) { /* no i18n on this host */ }
// Translate a tab's display name from the `tabs.<key>` catalog, keyed by the
// tab's stable `i18nKey` (preferred) or `id`. Falls back to the hardcoded

View file

@ -324,7 +324,25 @@ async function exportPoster(item) {
// `t` for the wall's own labels, `locale` for the card deck's language. Both come
// from the SAME instance so a label and a printed deck can never disagree about
// which language the reader chose.
const { t, locale: wallLocale } = useI18n()
// ★ i18n IS OPTIONAL IN A LIBRARY COMPONENT, NOT REQUIRED. useI18n() THROWS ("Need to install
// with `app.use` function") on any host that never installed vue-i18n, and seven bundles here do
// not — big-picture, doxa-maps-spa, doxa-vision-card, golden-path-check, people-groups-grid,
// plugin-studio, stack-demo. An unguarded call in a component this widely consumed is a BLANK
// PAGE waiting for the first bare host that renders it: exactly how library FilterDropdown
// blanked people-groups-grid, a production plugin, fixed by studio in 6603dc17. Same shape of
// fix as PinHint.vue.
//
// ⚠ THE FALLBACK IS DERIVED, NOT A TABLE. Hardcoding English for these eleven keys would put a
// second copy of the `en` catalog inside this file, and two copies of one fact drift — the exact
// trap behind the embedBase asset-path bug this week. Humanizing the last path segment gives
// "buttons.prayerList" -> "Prayer List", "buttons.cardDeck" -> "Card Deck",
// "buttons.posterMap" -> "Poster Map": readable English that CANNOT go stale, rather than a raw
// key shown to a reader.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
let t = (k) => humanizeKey(k)
let wallLocale = 'en'
try { const i18n = useI18n(); t = i18n.t; wallLocale = i18n.locale } catch (_) { /* no i18n on this host */ }
// ⚠ ONE exportState, not two. The composable owns a ref of its own; if the HUD
// keeps rendering the wall's while the print actions write to the composable's, a
// card-deck print shows NO progress and its errors vanish silently — the mechanism

View file

@ -0,0 +1,30 @@
{
"title": "استوديو الإضافات",
"navLabel": "الإضافات حسب القدرة",
"countLabel": "{bundles} حزمة · {elements} إضافة مدرجة هنا",
"changeLanguage": "تغيير اللغة",
"copyEmbed": "نسخ رمز التضمين",
"copy": "نسخ",
"copied": "تم النسخ",
"scriptEmbed": "تضمين عبر سكربت",
"loading": "جارٍ تحميل {name}…",
"couldNotLoad": "تعذّر تحميل {name}.",
"chooseElement": "اختر إضافة من القائمة.",
"filterPlaceholder": "تصفية الإضافات",
"collapseAll": "طي الكل",
"noMatch": "لا توجد إضافة تطابق «{query}».",
"openSection": "فتح {cap}، {label}",
"part": "جزء",
"noStandalone": "بلا صفحة مستقلة",
"cap5": "هيكل التطبيق",
"cap4": "مركّب",
"cap3": "متعدد العروض",
"cap2": "إعداد مسبق",
"cap1": "مفرد",
"toggleList": "إظهار القائمة أو إخفاؤها",
"openViews": "فتح قائمة العروض",
"expandList": "توسيع القائمة",
"collapseList": "طي القائمة",
"collapse": "طي",
"partOf": "جزء من {name}، غير منشور بمفرده"
}

View file

@ -0,0 +1,30 @@
{
"title": "Plugin-Studio",
"navLabel": "Plugins nach Fähigkeit",
"countLabel": "{bundles} Bundles · {elements} Plugins hier aufgeführt",
"changeLanguage": "Sprache ändern",
"copyEmbed": "Einbettung kopieren",
"copy": "Kopieren",
"copied": "Kopiert",
"scriptEmbed": "Skript-Einbettung",
"loading": "{name} wird geladen…",
"couldNotLoad": "{name} konnte nicht geladen werden.",
"chooseElement": "Wählen Sie ein Plugin aus der Liste.",
"filterPlaceholder": "Plugins filtern",
"collapseAll": "Alle einklappen",
"noMatch": "Kein Plugin entspricht „{query}“.",
"openSection": "{cap} öffnen, {label}",
"part": "Teil",
"noStandalone": "keine eigene Seite",
"cap5": "App-Shell",
"cap4": "Komposit",
"cap3": "Mehrfachansicht",
"cap2": "Voreinstellung",
"cap1": "Einzeln",
"toggleList": "Liste ein-/ausblenden",
"openViews": "Ansichtsliste öffnen",
"expandList": "Liste ausklappen",
"collapseList": "Liste einklappen",
"collapse": "Einklappen",
"partOf": "Teil von {name}, nicht eigenständig veröffentlicht"
}

View file

@ -0,0 +1,30 @@
{
"title": "Plugin Studio",
"navLabel": "Plugins by capability",
"countLabel": "{bundles} bundles · {elements} plugins listed here",
"changeLanguage": "Change language",
"copyEmbed": "Copy embed",
"copy": "Copy",
"copied": "Copied",
"scriptEmbed": "Script embed",
"loading": "Loading {name}…",
"couldNotLoad": "Could not load {name}.",
"chooseElement": "Choose a plugin from the list.",
"filterPlaceholder": "Filter plugins",
"collapseAll": "Collapse all",
"noMatch": "No plugin matches “{query}”.",
"openSection": "Open {cap}, {label}",
"part": "part",
"noStandalone": "no standalone page",
"cap5": "App shell",
"cap4": "Composite",
"cap3": "Multi-view",
"cap2": "Preset",
"cap1": "Single",
"toggleList": "Toggle list",
"openViews": "Open the list of views",
"expandList": "Expand list",
"collapseList": "Collapse list",
"collapse": "Collapse",
"partOf": "part of {name}, not published on its own"
}

View file

@ -0,0 +1,30 @@
{
"title": "Estudio de plugins",
"navLabel": "Plugins por capacidad",
"countLabel": "{bundles} paquetes · {elements} plugins listados aquí",
"changeLanguage": "Cambiar idioma",
"copyEmbed": "Copiar código de inserción",
"copy": "Copiar",
"copied": "Copiado",
"scriptEmbed": "Inserción por script",
"loading": "Cargando {name}…",
"couldNotLoad": "No se pudo cargar {name}.",
"chooseElement": "Elija un plugin de la lista.",
"filterPlaceholder": "Filtrar plugins",
"collapseAll": "Contraer todo",
"noMatch": "Ningún plugin coincide con «{query}».",
"openSection": "Abrir {cap}, {label}",
"part": "parte",
"noStandalone": "sin página propia",
"cap5": "Contenedor de aplicación",
"cap4": "Compuesto",
"cap3": "Multivista",
"cap2": "Preajuste",
"cap1": "Individual",
"toggleList": "Mostrar u ocultar la lista",
"openViews": "Abrir la lista de vistas",
"expandList": "Expandir la lista",
"collapseList": "Contraer la lista",
"collapse": "Contraer",
"partOf": "parte de {name}, no publicada por separado"
}

View file

@ -0,0 +1,30 @@
{
"title": "Studio de plugins",
"navLabel": "Plugins par capacité",
"countLabel": "{bundles} paquets · {elements} plugins listés ici",
"changeLanguage": "Changer de langue",
"copyEmbed": "Copier le code d’intégration",
"copy": "Copier",
"copied": "Copié",
"scriptEmbed": "Intégration par script",
"loading": "Chargement de {name}…",
"couldNotLoad": "Impossible de charger {name}.",
"chooseElement": "Choisissez un plugin dans la liste.",
"filterPlaceholder": "Filtrer les plugins",
"collapseAll": "Tout replier",
"noMatch": "Aucun plugin ne correspond à « {query} ».",
"openSection": "Ouvrir {cap}, {label}",
"part": "partie",
"noStandalone": "pas de page autonome",
"cap5": "Coquille d’application",
"cap4": "Composite",
"cap3": "Multivue",
"cap2": "Préréglage",
"cap1": "Simple",
"toggleList": "Afficher ou masquer la liste",
"openViews": "Ouvrir la liste des vues",
"expandList": "Déplier la liste",
"collapseList": "Replier la liste",
"collapse": "Replier",
"partOf": "partie de {name}, non publiée seule"
}

View file

@ -0,0 +1,30 @@
{
"title": "प्लगइन स्टूडियो",
"navLabel": "क्षमता के अनुसार प्लगइन",
"countLabel": "{bundles} बंडल · यहाँ {elements} प्लगइन सूचीबद्ध",
"changeLanguage": "भाषा बदलें",
"copyEmbed": "एम्बेड कोड कॉपी करें",
"copy": "कॉपी करें",
"copied": "कॉपी हो गया",
"scriptEmbed": "स्क्रिप्ट एम्बेड",
"loading": "{name} लोड हो रहा है…",
"couldNotLoad": "{name} लोड नहीं हो सका।",
"chooseElement": "सूची से कोई प्लगइन चुनें।",
"filterPlaceholder": "प्लगइन फ़िल्टर करें",
"collapseAll": "सभी संक्षिप्त करें",
"noMatch": "“{query}” से कोई प्लगइन मेल नहीं खाता।",
"openSection": "{cap} खोलें, {label}",
"part": "भाग",
"noStandalone": "कोई अलग पेज नहीं",
"cap5": "ऐप शेल",
"cap4": "संयुक्त",
"cap3": "बहु-दृश्य",
"cap2": "प्रीसेट",
"cap1": "एकल",
"toggleList": "सूची दिखाएँ या छिपाएँ",
"openViews": "दृश्यों की सूची खोलें",
"expandList": "सूची फैलाएँ",
"collapseList": "सूची संक्षिप्त करें",
"collapse": "संक्षिप्त करें",
"partOf": "{name} का भाग, अलग से प्रकाशित नहीं"
}

View file

@ -0,0 +1,30 @@
{
"title": "Studio dei plugin",
"navLabel": "Plugin per capacità",
"countLabel": "{bundles} pacchetti · {elements} plugin elencati qui",
"changeLanguage": "Cambia lingua",
"copyEmbed": "Copia codice di incorporamento",
"copy": "Copia",
"copied": "Copiato",
"scriptEmbed": "Incorporamento tramite script",
"loading": "Caricamento di {name}…",
"couldNotLoad": "Impossibile caricare {name}.",
"chooseElement": "Scegli un plugin dall’elenco.",
"filterPlaceholder": "Filtra i plugin",
"collapseAll": "Comprimi tutto",
"noMatch": "Nessun plugin corrisponde a «{query}».",
"openSection": "Apri {cap}, {label}",
"part": "parte",
"noStandalone": "nessuna pagina propria",
"cap5": "Shell dell’app",
"cap4": "Composito",
"cap3": "Multivista",
"cap2": "Preimpostato",
"cap1": "Singolo",
"toggleList": "Mostra o nascondi l’elenco",
"openViews": "Apri l’elenco delle viste",
"expandList": "Espandi l’elenco",
"collapseList": "Comprimi l’elenco",
"collapse": "Comprimi",
"partOf": "parte di {name}, non pubblicata da sola"
}

View file

@ -0,0 +1,30 @@
{
"title": "Estúdio de plugins",
"navLabel": "Plugins por capacidade",
"countLabel": "{bundles} pacotes · {elements} plugins listados aqui",
"changeLanguage": "Mudar idioma",
"copyEmbed": "Copiar código de incorporação",
"copy": "Copiar",
"copied": "Copiado",
"scriptEmbed": "Incorporação por script",
"loading": "Carregando {name}…",
"couldNotLoad": "Não foi possível carregar {name}.",
"chooseElement": "Escolha um plugin na lista.",
"filterPlaceholder": "Filtrar plugins",
"collapseAll": "Recolher tudo",
"noMatch": "Nenhum plugin corresponde a “{query}”.",
"openSection": "Abrir {cap}, {label}",
"part": "parte",
"noStandalone": "sem página própria",
"cap5": "Shell do aplicativo",
"cap4": "Composto",
"cap3": "Multivisualização",
"cap2": "Predefinição",
"cap1": "Único",
"toggleList": "Mostrar ou ocultar a lista",
"openViews": "Abrir a lista de visualizações",
"expandList": "Expandir a lista",
"collapseList": "Recolher a lista",
"collapse": "Recolher",
"partOf": "parte de {name}, não publicada separadamente"
}

View file

@ -0,0 +1,30 @@
{
"title": "Studio de pluginuri",
"navLabel": "Pluginuri după capacitate",
"countLabel": "{bundles} pachete · {elements} pluginuri listate aici",
"changeLanguage": "Schimbă limba",
"copyEmbed": "Copiază codul de încorporare",
"copy": "Copiază",
"copied": "Copiat",
"scriptEmbed": "Încorporare prin script",
"loading": "Se încarcă {name}…",
"couldNotLoad": "{name} nu a putut fi încărcat.",
"chooseElement": "Alegeți un plugin din listă.",
"filterPlaceholder": "Filtrează pluginurile",
"collapseAll": "Restrânge tot",
"noMatch": "Niciun plugin nu corespunde cu „{query}”.",
"openSection": "Deschide {cap}, {label}",
"part": "parte",
"noStandalone": "fără pagină proprie",
"cap5": "Cadru de aplicație",
"cap4": "Compus",
"cap3": "Vizualizări multiple",
"cap2": "Presetat",
"cap1": "Simplu",
"toggleList": "Afișează sau ascunde lista",
"openViews": "Deschide lista de vizualizări",
"expandList": "Extinde lista",
"collapseList": "Restrânge lista",
"collapse": "Restrânge",
"partOf": "parte din {name}, nepublicată separat"
}

View file

@ -0,0 +1,30 @@
{
"title": "Студия плагинов",
"navLabel": "Плагины по возможностям",
"countLabel": "Здесь: пакетов {bundles} · плагинов {elements}",
"changeLanguage": "Сменить язык",
"copyEmbed": "Скопировать код встраивания",
"copy": "Копировать",
"copied": "Скопировано",
"scriptEmbed": "Встраивание через скрипт",
"loading": "Загрузка {name}…",
"couldNotLoad": "Не удалось загрузить {name}.",
"chooseElement": "Выберите плагин из списка.",
"filterPlaceholder": "Фильтр плагинов",
"collapseAll": "Свернуть всё",
"noMatch": "Нет плагинов, соответствующих «{query}».",
"openSection": "Открыть {cap}, {label}",
"part": "часть",
"noStandalone": "нет отдельной страницы",
"cap5": "Оболочка приложения",
"cap4": "Составной",
"cap3": "Несколько видов",
"cap2": "Предустановка",
"cap1": "Одиночный",
"toggleList": "Показать или скрыть список",
"openViews": "Открыть список представлений",
"expandList": "Развернуть список",
"collapseList": "Свернуть список",
"collapse": "Свернуть",
"partOf": "часть {name}, не публикуется отдельно"
}

View file

@ -0,0 +1,30 @@
{
"title": "插件工作室",
"navLabel": "按能力分类的插件",
"countLabel": "此处列出 {bundles} 个包 · {elements} 个插件",
"changeLanguage": "切换语言",
"copyEmbed": "复制嵌入代码",
"copy": "复制",
"copied": "已复制",
"scriptEmbed": "脚本嵌入",
"loading": "正在加载 {name}…",
"couldNotLoad": "无法加载 {name}。",
"chooseElement": "请从列表中选择一个插件。",
"filterPlaceholder": "筛选插件",
"collapseAll": "全部折叠",
"noMatch": "没有与“{query}”匹配的插件。",
"openSection": "打开 {cap},{label}",
"part": "部分",
"noStandalone": "无独立页面",
"cap5": "应用外壳",
"cap4": "组合",
"cap3": "多视图",
"cap2": "预设",
"cap1": "单一",
"toggleList": "显示或隐藏列表",
"openViews": "打开视图列表",
"expandList": "展开列表",
"collapseList": "折叠列表",
"collapse": "折叠",
"partOf": "{name} 的一部分,不单独发布"
}

View file

@ -94,7 +94,7 @@
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header }">
<header class="vtl-top" v-if="header">
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
aria-label="Toggle list">☰</button>
:aria-label="label('toggleList')">☰</button>
<span class="vtl-brand"><slot name="brand" /></span>
<b class="vtl-title">{{ title }}</b>
<span class="vtl-meta"><slot name="meta" /></span>
@ -114,7 +114,7 @@
floating button over an open drawer would just cover a row. -->
<button v-if="narrow && !header && !navOpen"
class="vtl-fab" type="button" :aria-expanded="String(navOpen)"
@click="navOpen = true" aria-label="Open the list of views">☰</button>
@click="navOpen = true" :aria-label="label('openViews')">☰</button>
<div class="vtl-body">
<!-- Rail visibility is CSS-driven via `transform`, never v-show/display:none — a display:none element
@ -130,10 +130,10 @@
<button
type="button" class="vtl-collapse"
:aria-expanded="String(!railCollapsed)"
:aria-label="railCollapsed ? 'Expand list' : 'Collapse list'"
:aria-label="railCollapsed ? label('expandList') : label('collapseList')"
@click="toggleRail">
<span class="vtl-collapse-ic" aria-hidden="true" v-html="iconSvg('chevron')"></span>
<span class="vtl-collapse-tx">Collapse</span>
<span class="vtl-collapse-tx">{{ label('collapse') }}</span>
</button>
<!-- #rail: a host that needs MORE than tabs (e.g. plugin-studio's tree) supplies the whole rail
@ -511,6 +511,9 @@ export default defineComponent({
// When `groups` is non-empty it takes precedence over `items`.
groups: { type: Array, default: () => [] },
navLabel: { type: String, default: 'Sections' },
// The shell's own words, for a localised host: { toggleList, openViews, expandList, collapseList,
// collapse }. Any key left out keeps its English default, so existing hosts are unchanged.
labels: { type: Object, default: () => ({}) },
// Start with the rail open (desktop). Auto-collapses on narrow screens after mount.
initialNavOpen: { type: Boolean, default: true },
// Render the top bar (#brand / title / #meta). Pass false for a sidebar-only, full-page element
@ -618,6 +621,10 @@ export default defineComponent({
this.navOpen = true
}
},
label(key) {
const EN = { toggleList: 'Toggle list', openViews: 'Open the list of views', expandList: 'Expand list', collapseList: 'Collapse list', collapse: 'Collapse' }
return (this.labels && this.labels[key]) || EN[key]
},
// Open the desktop rail if it is in its slim state (a #rail host calls this when its slim-strip
// icons are clicked: "show me that section" means the full rail). No-op when already open.
expandRail() { if (!this.narrow && this.railCollapsed) this.railCollapsed = false },

View file

@ -32,6 +32,7 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { DataSourceManager } from '@map/utils/DataSourceManager.js'
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
useShadowStyles(`
.apg-root { position:absolute;inset:0;overflow:hidden;
@ -90,8 +91,9 @@ const isDark = computed(() => profileConfig.value?.isDark === true)
const SHARE_EMBED_PATH = 'all-people-groups/all-people-groups/index.html'
const mapProvider = resolveMapProvider(profileConfig)
const assetsBase = computed(() =>
String(profileConfig?.value?.assetsBase || '/assets').replace(/\/+$/, ''))
// Through the shared resolver, not a '/assets' fallback: that root path 404s whenever the tree
// is served below the site root, and inside the Research Gallery no assetsBase is passed at all.
const assetsBase = computed(() => resolveAssetsBase(profileConfig?.value || {}))
// ─── The four categories (the Driver's palette) ──────────────────────────────
const CATS = [

View file

@ -51,8 +51,9 @@
model + owns everything in the content area: the embed bar (as #toolbar) and the per-element
panes with their direct-element mount + heavy-row teardown machinery. -->
<VerticalTabsLayout
:title="heading"
nav-label="Plugin elements · by capability 5 → 1"
:title="heading || tr('title')"
:nav-label="tr('navLabel')"
:labels="{ toggleList: tr('toggleList'), openViews: tr('openViews'), expandList: tr('expandList'), collapseList: tr('collapseList'), collapse: tr('collapse') }"
:header="true"
:active="activeKey"
@update:active="select">
@ -60,7 +61,8 @@
<!-- #rail: the Explorer tree (src/StudioTree.vue) — capability sections, each composite folding
to show what it is made of. The layout keeps the rail frame (collapse, drawer, slim strip). -->
<template #rail="{ collapsed, expand, pick }">
<StudioTree :sections="treeSections" :active="activeKey" :collapsed="collapsed" :expand="expand" :pick="pick" />
<StudioTree :sections="treeSections" :active="activeKey" :collapsed="collapsed" :expand="expand" :pick="pick"
:strings="{ filterPlaceholder: tr('filterPlaceholder'), collapseAll: tr('collapseAll'), noMatch: tr('noMatch'), treeLabel: tr('navLabel'), openSection: tr('openSection'), part: tr('part') }" />
</template>
<!-- #brand: the studio's logo mark (a plug/puzzle glyph — "plugin studio"), left of the title.
@ -82,7 +84,7 @@
<span class="ps-count">{{ countLabel }}</span>
<span class="ps-locale">
<button type="button" class="ps-locale-btn" :aria-expanded="String(localeOpen)"
aria-label="Change language" @click="localeOpen = !localeOpen">
:aria-label="tr('changeLanguage')" @click="localeOpen = !localeOpen">
<span class="ps-flag">{{ activeLang.flag }}</span>
<span class="ps-locale-name">{{ activeLang.nativeName }}</span>
<span class="ps-dots" aria-hidden="true">⋮</span>
@ -111,12 +113,12 @@
<span class="ps-bar-sub">{{ active.bundle }} · &lt;{{ active.tag }}&gt;<span v-if="active.description"> · {{ active.description }}</span></span>
</div>
<button type="button" class="ps-btn" :aria-expanded="String(embedOpen)"
@click="embedOpen = !embedOpen" v-if="active.embed">Copy embed</button>
@click="embedOpen = !embedOpen" v-if="active.embed">{{ tr('copyEmbed') }}</button>
</div>
<div class="ps-embed" v-if="embedOpen && active.embed">
<label>script embed
<button type="button" class="ps-btn" @click="copyText(active.embed, $event.target)">Copy</button>
<label>{{ tr('scriptEmbed') }}
<button type="button" class="ps-btn" @click="copyText(active.embed, $event.target)">{{ tr('copy') }}</button>
</label>
<textarea readonly :value="active.embed"></textarea>
</div>
@ -142,13 +144,13 @@
<template v-if="!row.heavy || row.key === activeKey">
<div class="ps-slot" :ref="el => setSlot(row.key, el)"></div>
<div v-if="row.state === 'loading'" class="ps-hint">Loading {{ row.label }}…</div>
<div v-if="row.state === 'loading'" class="ps-hint">{{ tr('loading', { name: row.label }) }}</div>
<div v-else-if="row.state === 'error'" class="ps-hint err">
Could not load {{ row.label }}.<br /><small>{{ row.error }}</small>
{{ tr('couldNotLoad', { name: row.label }) }}<br /><small>{{ row.error }}</small>
</div>
</template>
</section>
<div class="ps-hint" v-if="!activeKey">Choose an element from the list.</div>
<div class="ps-hint" v-if="!activeKey">{{ tr('chooseElement') }}</div>
</div>
</VerticalTabsLayout>
</template>
@ -255,8 +257,9 @@ const capFor = (t) => (PT[t] != null ? 5 - PT[t] : 1)
// Rail row icon per capability: LOCAL inline SVG names (app-wrapper-material/icons.js), not emoji.
// These were emoji until 2026-09-24, and on a box with no emoji font (measured: this one) every rail
// row led with an empty tofu box — the first thing a developer saw on every plugin.
// Short tier names for the rail's section headers (the full CAP_LABEL is their tooltip).
const CAP_SHORT = { 5: 'App shell', 4: 'Composite', 3: 'Multi-view', 2: 'Preset', 1: 'Single' }
// Machine names of the five tiers — technical terms, shown untranslated after the localised tier name
// in a section header's tooltip (the localised names live in studio.json cap1…cap5).
const MACHINE_TYPE = { 5: 'nested-mfe-spa', 4: 'nested-elements-mfe', 3: 'monolithic', 2: 'parameterized', 1: 'single' }
const CAP_ICON = { 5: 'monitor', 4: 'tree', 3: 'layers', 2: 'grid', 1: 'embed' }
// MAP ELEMENTS. These own a live WebGL context, so they are the rows the studio DESTROYS on
@ -302,6 +305,17 @@ const HEAVY_ELEMENTS = new Set([
// doxa-maps-spa's NOT_EXPERIMENTAL already hides big-picture for the same reason.
const STUDIO_HIDDEN = new Set(['big-picture'])
// THE STUDIO'S OWN WORDS, in every locale the switcher offers (library/i18n/locales/<lc>/studio.json,
// held to the same key parity as every other catalog by check:i18n-parity). The studio has no
// vue-i18n instance — it is a shell, not a ProfileLoader plugin — so it looks strings up directly
// and falls back to English per key. Before this, choosing a language translated the plugins
// but left the studio's own chrome in English.
const STUDIO_STRINGS = {}
for (const [path, mod] of Object.entries(import.meta.glob('../../library/i18n/locales/*/studio.json', { eager: true, import: 'default' }))) {
const m = path.match(/locales\/([^/]+)\/studio\.json$/)
if (m) STUDIO_STRINGS[m[1]] = mod
}
// DISPLAY NAMES — what a row is CALLED, keyed by "<bundle>/<profile>". Display only: the row key, the
// URL hash, the stored last-tab and the <tag> under the name are all unchanged, so links and memory
// keep working. The Driver's name for the cap-5 shell is "Research Gallery" (its meta.json label);
@ -371,7 +385,8 @@ export default defineComponent({
// Renaming the PROP is safe because nothing outside ever set it: no host passes title=, no
// profile-config reads cfg.title, and the value is only forwarded to VerticalTabsLayout — an
// ordinary Vue component, never a custom element, so its own `title` prop has no collision.
heading: { type: String, default: 'Plugin Studio' },
// Empty = the localised "Plugin Studio" (studio.json `title`); a host may still set its own.
heading: { type: String, default: '' },
tokenUrl: { type: String, default: '' },
tk: { type: String, default: '' }
},
@ -441,7 +456,7 @@ export default defineComponent({
const t = treeOf.get(r.key)
nodes.push({
id: r.key, parent: '', depth: 0, label: r.label, desc: r.tag, icon: r.icon, target: r.key,
kids: t ? t.nodes[0].kids : 0, title: r.label + ' · <' + r.tag + '> · CAP ' + r.cap + ' ' + (CAP_LABEL[r.cap] || '')
kids: t ? t.nodes[0].kids : 0, title: r.label + ' · <' + r.tag + '> · CAP ' + r.cap + ' ' + this.tr('cap' + r.cap)
})
if (!t) continue
const nid = (id) => (id === 0 ? r.key : r.key + '>' + id)
@ -450,16 +465,16 @@ export default defineComponent({
id: nid(n.id), parent: nid(n.ancestors[n.ancestors.length - 1]), depth: n.depth,
label: n.label, part: !!n.part, target: n.selects || '',
icon: n.part ? 'dots' : (CAP_ICON[n.cap] || 'embed'),
desc: n.part ? 'no standalone page' : 'cap ' + n.cap + ' · ' + n.tag,
desc: n.part ? this.tr('noStandalone') : 'cap ' + n.cap + ' · ' + n.tag,
kids: n.kids || 0,
title: n.part ? n.label + ' · part of ' + r.label + ', not published on its own'
: n.label + ' · <' + n.tag + '> · CAP ' + n.cap + ' ' + (CAP_LABEL[n.cap] || '')
title: n.part ? n.label + ' · ' + this.tr('partOf', { name: r.label })
: n.label + ' · <' + n.tag + '> · CAP ' + n.cap + ' ' + this.tr('cap' + n.cap)
})
}
}
// Short section names: the header shares a 256px rail with its CAP badge and count, and the
// full "App shell · nested-mfe-spa" truncated to "APP SHELL · NEST…". The long name is the tooltip.
return { id: 'cap-' + g.cap, cap: g.cap, label: CAP_SHORT[g.cap] || g.label, title: g.label, icon: CAP_ICON[g.cap] || 'embed', nodes }
return { id: 'cap-' + g.cap, cap: g.cap, label: this.tr('cap' + g.cap), title: this.tr('cap' + g.cap) + ' · ' + (MACHINE_TYPE[g.cap] || ''), icon: CAP_ICON[g.cap] || 'embed', nodes }
})
},
// ★ ONE TREE PER COMPOSITE, built ENTIRELY from the manifest's `composes` edges — no bundle names
@ -530,7 +545,7 @@ export default defineComponent({
// total reads as a deliberate exclusion rather than an off-by-one.
countLabel() {
const nb = new Set(this.rows.map(r => r.bundle)).size
return nb + ' bundles · ' + this.rows.length + ' elements listed here'
return this.tr('countLabel', { bundles: nb, elements: this.rows.length })
},
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } }
},
@ -538,6 +553,13 @@ export default defineComponent({
this.loadManifest()
},
methods: {
// Localised studio string: current locale → English → the key itself; {name}-style placeholders.
tr(key, vars) {
const base = (this.locale || 'en').split('-')[0]
let str = (STUDIO_STRINGS[this.locale] || {})[key] || (STUDIO_STRINGS[base] || {})[key] || (STUDIO_STRINGS.en || {})[key] || key
if (vars) for (const k in vars) str = str.split('{' + k + '}').join(String(vars[k]))
return str
},
// Resolve one `composes` entry to a studio row. The manifest's refs are NOT one shape: an
// element TAG, a "<bundle>/<profile>" row KEY, a BUNDLE name whose tag differs,
// or a bare SIBLING profile name (gospel-resources composes "resources-map", "data-tables" — its
@ -785,7 +807,7 @@ export default defineComponent({
})
},
copyText(text, btn) {
const ok = () => { const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t }, 1200) }
const ok = () => { const t = btn.textContent; btn.textContent = this.tr('copied'); setTimeout(() => { btn.textContent = t }, 1200) }
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, () => {})
else {
try { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); ok() } catch { /* ignore */ }

View file

@ -36,7 +36,7 @@
<div v-if="collapsed" class="st-activity">
<button v-for="s in sections" :key="s.id" type="button" class="st-act"
:class="{ active: s.id === activeSectionId }"
:aria-label="'Open CAP ' + s.cap + ', ' + s.label"
:aria-label="str('openSection', { cap: 'CAP ' + s.cap, label: s.label })"
@click="openSectionFromActivity(s.id)">
<span class="st-act-ic" aria-hidden="true" v-html="svg(s.icon)"></span>
<span class="st-act-n" aria-hidden="true">{{ s.cap }}</span>
@ -47,15 +47,15 @@
<div class="st-bar">
<label class="st-find">
<span class="st-find-ic" aria-hidden="true" v-html="svg('search')"></span>
<input ref="find" type="search" class="st-find-in" v-model="query" placeholder="Filter plugins"
aria-label="Filter plugins" @keydown="onFindKey" />
<input ref="find" type="search" class="st-find-in" v-model="query" :placeholder="str('filterPlaceholder')"
:aria-label="str('filterPlaceholder')" @keydown="onFindKey" />
</label>
<button type="button" class="st-tool" aria-label="Collapse all" title="Collapse all" @click="collapseAll">
<button type="button" class="st-tool" :aria-label="str('collapseAll')" :title="str('collapseAll')" @click="collapseAll">
<span aria-hidden="true" v-html="collapseAllSvg"></span>
</button>
</div>
<div class="st-tree" role="tree" aria-label="Plugins by capability" @keydown="onTreeKey">
<div class="st-tree" role="tree" :aria-label="str('treeLabel')" @keydown="onTreeKey">
<div v-for="item in visible" :key="item.id"
:ref="el => setRow(item.id, el)"
role="treeitem"
@ -80,12 +80,12 @@
<span v-else class="st-twisty-sp" aria-hidden="true"></span>
<span class="st-ic" aria-hidden="true" v-html="svg(item.icon)"></span>
<span class="st-label">{{ item.label }}</span>
<span class="st-part" v-if="item.part">part</span>
<span class="st-part" v-if="item.part">{{ str('part') }}</span>
<span class="st-desc">{{ item.desc }}</span>
<span class="st-count" v-if="item.expandable">{{ item.kids }}</span>
</template>
</div>
<p class="st-empty" v-if="query && !visible.length">No plugin matches “{{ query }}”.</p>
<p class="st-empty" v-if="query && !visible.length">{{ str('noMatch', { query }) }}</p>
</div>
</div>
</template>
@ -139,6 +139,10 @@ const ST_CSS = '' +
'.st-act-n { position:absolute; right:6px; bottom:5px; font:700 9px/1 system-ui,sans-serif; color:#5f8a63; }'
const COLLAPSE_ALL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="3"/><line x1="8.5" y1="12" x2="15.5" y2="12"/></svg>'
const EN = {
filterPlaceholder: 'Filter plugins', collapseAll: 'Collapse all', noMatch: 'No plugin matches “{query}”.',
treeLabel: 'Plugins by capability', openSection: 'Open {cap}, {label}', part: 'part'
}
const CHEV = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>'
export default defineComponent({
@ -150,7 +154,9 @@ export default defineComponent({
active: { type: String, default: '' },
collapsed: { type: Boolean, default: false },
pick: { type: Function, default: null },
expand: { type: Function, default: null }
expand: { type: Function, default: null },
// The host's localised strings (plugin-studio passes studio.json values); English fallbacks below.
strings: { type: Object, default: () => ({}) }
},
emits: ['select'],
setup() { useShadowStyles(ST_CSS, 'studio-tree') },
@ -221,6 +227,11 @@ export default defineComponent({
},
methods: {
svg(name) { return name ? localIcon(name) : '' },
str(key, vars) {
let v = this.strings[key] || EN[key] || key
if (vars) for (const k in vars) v = v.split('{' + k + '}').join(String(vars[k]))
return v
},
storageKey() { return 'dx-studio-tree:' + ((typeof location !== 'undefined' && location.pathname) || '') },
// Guarded: storage THROWS in private mode / under policy, and a throw here would blank the studio.
readState() { try { return JSON.parse(window.localStorage.getItem(this.storageKey()) || '{}') || {} } catch (_) { return {} } },

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
<script src="../all-people-groups.js" integrity="sha384-j2rnWAW0lVUG05WREAfkQZo0jtreSIebwV3nHCbUcXhAGzMfTESwA1PqTYy5M3QS" crossorigin="anonymous"></script>
<script src="../all-people-groups.js" integrity="sha384-wDwlEPqFMo1DGCSAZQqHdBUM9OKTByNW1wSZPNu5auSubOuSBz4eHR4CIf0uvglf" 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

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 22,
"profileCount": 35,
"bundleCount": 21,
"profileCount": 33,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life",
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
@ -13,7 +13,7 @@
"bundle": "all-people-groups",
"scriptUrl": "./all-people-groups/all-people-groups.js",
"output": "doxa-maps/all-people-groups/all-people-groups.js",
"integrity": "sha384-j2rnWAW0lVUG05WREAfkQZo0jtreSIebwV3nHCbUcXhAGzMfTESwA1PqTYy5M3QS",
"integrity": "sha384-wDwlEPqFMo1DGCSAZQqHdBUM9OKTByNW1wSZPNu5auSubOuSBz4eHR4CIf0uvglf",
"elements": [
"all-people-groups"
],
@ -29,7 +29,7 @@
"scriptUrl": "./all-people-groups/all-people-groups.js",
"embed": {
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/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=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-j2rnWAW0lVUG05WREAfkQZo0jtreSIebwV3nHCbUcXhAGzMfTESwA1PqTYy5M3QS\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-wDwlEPqFMo1DGCSAZQqHdBUM9OKTByNW1wSZPNu5auSubOuSBz4eHR4CIf0uvglf\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
},
"bundle": "all-people-groups"
}
@ -681,7 +681,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "doxa-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-nzolEnrOfJrY1qd67PyWpwNB6JFgKTsmIaCpvpL+XAdfmuMYmNGf0NAlk/Gi79Ak",
"integrity": "sha384-mb0a3w3W6EiyAv5tQ6SpKpEbigF2HIArUes+z4SG2uobtD+siUkdkTLvBAzqSidO",
"elements": [
"my-upg-100-list-map"
],
@ -701,7 +701,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-nzolEnrOfJrY1qd67PyWpwNB6JFgKTsmIaCpvpL+XAdfmuMYmNGf0NAlk/Gi79Ak\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-mb0a3w3W6EiyAv5tQ6SpKpEbigF2HIArUes+z4SG2uobtD+siUkdkTLvBAzqSidO\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -715,7 +715,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-nzolEnrOfJrY1qd67PyWpwNB6JFgKTsmIaCpvpL+XAdfmuMYmNGf0NAlk/Gi79Ak\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-mb0a3w3W6EiyAv5tQ6SpKpEbigF2HIArUes+z4SG2uobtD+siUkdkTLvBAzqSidO\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -729,7 +729,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-nzolEnrOfJrY1qd67PyWpwNB6JFgKTsmIaCpvpL+XAdfmuMYmNGf0NAlk/Gi79Ak\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-mb0a3w3W6EiyAv5tQ6SpKpEbigF2HIArUes+z4SG2uobtD+siUkdkTLvBAzqSidO\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]
@ -799,7 +799,7 @@
"bundle": "plugin-studio",
"scriptUrl": "./plugin-studio/plugin-studio.js",
"output": "doxa-maps/plugin-studio/plugin-studio.js",
"integrity": "sha384-PWsWQsPshQsOrFOoSZ3rPg6231BJmKV/pvopXruEZgKovSfp8P2MEjfEEEuBWqMl",
"integrity": "sha384-NXCBL46038pXHJb8lEhrC/U2ZIBLvsevalJdp9/OU/8cp3LGx2TinkGoY6j8usV3",
"elements": [
"plugin-studio"
],
@ -827,7 +827,7 @@
"scriptUrl": "./plugin-studio/plugin-studio.js",
"embed": {
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/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=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-PWsWQsPshQsOrFOoSZ3rPg6231BJmKV/pvopXruEZgKovSfp8P2MEjfEEEuBWqMl\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-NXCBL46038pXHJb8lEhrC/U2ZIBLvsevalJdp9/OU/8cp3LGx2TinkGoY6j8usV3\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
},
"instances": [
{
@ -1046,47 +1046,6 @@
}
]
},
{
"bundle": "template-bundle",
"scriptUrl": "./template-bundle/template-bundle.js",
"output": "doxa-maps/template-bundle/template-bundle.js",
"integrity": "sha384-e6IcmUAvFTc057fmxLFzZmjVnt7Svy9M+S4gXgGbU9XgmssqVY1dwW5UkHI766a+",
"elements": [
"template-bundle"
],
"profiles": [
{
"profile": "template-map-a",
"file": "template-map-a.vue",
"description": "Minimal example profile A — a colored placeholder, no Mapbox or data.",
"element": "template-bundle",
"type": "single",
"isMap": false,
"iframeUrl": "./template-bundle/template-map-a/index.html",
"scriptUrl": "./template-bundle/template-bundle.js",
"embed": {
"iframe": "<iframe src=\"./template-bundle/template-map-a/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=\"./template-bundle/template-bundle.js\" integrity=\"sha384-e6IcmUAvFTc057fmxLFzZmjVnt7Svy9M+S4gXgGbU9XgmssqVY1dwW5UkHI766a+\" crossorigin=\"anonymous\"></script>\n<template-bundle profile-config='{\"profile\":\"template-map-a\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></template-bundle>"
},
"bundle": "template-bundle"
},
{
"profile": "template-map-b",
"file": "template-map-b.vue",
"description": "Minimal example profile B — a second profile in the same bundle.",
"element": "template-bundle",
"type": "single",
"isMap": false,
"iframeUrl": "./template-bundle/template-map-b/index.html",
"scriptUrl": "./template-bundle/template-bundle.js",
"embed": {
"iframe": "<iframe src=\"./template-bundle/template-map-b/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=\"./template-bundle/template-bundle.js\" integrity=\"sha384-e6IcmUAvFTc057fmxLFzZmjVnt7Svy9M+S4gXgGbU9XgmssqVY1dwW5UkHI766a+\" crossorigin=\"anonymous\"></script>\n<template-bundle profile-config='{\"profile\":\"template-map-b\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></template-bundle>"
},
"bundle": "template-bundle"
}
]
},
{
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-nzolEnrOfJrY1qd67PyWpwNB6JFgKTsmIaCpvpL+XAdfmuMYmNGf0NAlk/Gi79Ak" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-mb0a3w3W6EiyAv5tQ6SpKpEbigF2HIArUes+z4SG2uobtD+siUkdkTLvBAzqSidO" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-nzolEnrOfJrY1qd67PyWpwNB6JFgKTsmIaCpvpL+XAdfmuMYmNGf0NAlk/Gi79Ak" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-mb0a3w3W6EiyAv5tQ6SpKpEbigF2HIArUes+z4SG2uobtD+siUkdkTLvBAzqSidO" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-nzolEnrOfJrY1qd67PyWpwNB6JFgKTsmIaCpvpL+XAdfmuMYmNGf0NAlk/Gi79Ak" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-mb0a3w3W6EiyAv5tQ6SpKpEbigF2HIArUes+z4SG2uobtD+siUkdkTLvBAzqSidO" 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

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
<script src="../plugin-studio.js" integrity="sha384-PWsWQsPshQsOrFOoSZ3rPg6231BJmKV/pvopXruEZgKovSfp8P2MEjfEEEuBWqMl" crossorigin="anonymous"></script>
<script src="../plugin-studio.js" integrity="sha384-NXCBL46038pXHJb8lEhrC/U2ZIBLvsevalJdp9/OU/8cp3LGx2TinkGoY6j8usV3" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
<script src="../../plugin-studio.js" integrity="sha384-PWsWQsPshQsOrFOoSZ3rPg6231BJmKV/pvopXruEZgKovSfp8P2MEjfEEEuBWqMl" crossorigin="anonymous"></script>
<script src="../../plugin-studio.js" integrity="sha384-NXCBL46038pXHJb8lEhrC/U2ZIBLvsevalJdp9/OU/8cp3LGx2TinkGoY6j8usV3" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -1,168 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>template-map-a · template-bundle</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. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, template-bundle { position: fixed; inset: 0; width: 100%; height: 100%; 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; }
</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 <template-bundle> custom element. -->
<script src="../template-bundle.js" integrity="sha384-e6IcmUAvFTc057fmxLFzZmjVnt7Svy9M+S4gXgGbU9XgmssqVY1dwW5UkHI766a+" 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":"template-map-a","dataSource":"pray-tools","instanceId":"template-bundle-template-map-a","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.
var TOKEN_FREE = true;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// 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.
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. The Driver is
PHOTOSENSITIVE and a 60fps resize-strobe was already fixed this session; do NOT turn this into
a per-frame or per-event handler. */
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(); }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("template-bundle");
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 || BAKED_TOKEN;
if (TOKEN_FREE) { return mount(''); }
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: say which, and still offer the paste-once box (no dead ends).
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

@ -1,168 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>template-map-b · template-bundle</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. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, template-bundle { position: fixed; inset: 0; width: 100%; height: 100%; 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; }
</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 <template-bundle> custom element. -->
<script src="../template-bundle.js" integrity="sha384-e6IcmUAvFTc057fmxLFzZmjVnt7Svy9M+S4gXgGbU9XgmssqVY1dwW5UkHI766a+" 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":"template-map-b","dataSource":"pray-tools","instanceId":"template-bundle-template-map-b","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.
var TOKEN_FREE = true;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// 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.
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. The Driver is
PHOTOSENSITIVE and a 60fps resize-strobe was already fixed this session; do NOT turn this into
a per-frame or per-event handler. */
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(); }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("template-bundle");
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 || BAKED_TOKEN;
if (TOKEN_FREE) { return mount(''); }
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: say which, and still offer the paste-once box (no dead ends).
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>