feat(qa): QA corner widget on maps (opt-in), on for the DOXA SPA surface
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

Adds library/components/qa/QaCorner.vue — a bottom-right floating 'QA' button +
panel (OIDC-onboarding shell: 'Log in with Nuxtinator', no key), rendered by
ProfileLoader over any profile. OPT-IN via profile-config.qa (default OFF) so the
same bundles that power partner embeds never show a QA button; the SPA shell sets
qa:true on every mount so it appears on all maps in the power app. Backend (qhoot /
nuxtbase-baas agent bridge + MCP) wired in a later pass — this is the visible shell.
Spec: _qa-cycle/01-ideation-research/qa-loops/qa-embeddable-qa-agent-bridge.md
This commit is contained in:
doxa-maps 2026-09-14 21:47:59 +00:00
parent 63bc1d77c1
commit 9ab4ea2d44
19 changed files with 361 additions and 1090 deletions

View file

@ -984,6 +984,8 @@ async function mount(id){
// a subpath (e.g. /plugin-assets/doxa-maps-spa/), a bare '/assets' or './assets' misresolves,
// so hand each bundle an ABSOLUTE base derived from THIS shell's own document — correct at any depth.
if (!cfg.assetsBase) cfg.assetsBase = new URL('assets', document.baseURI).href.replace(/\/+$/, '')
// QA corner widget ON for the go-base SPA surface (opt-in flag; off for partner embeds).
cfg.qa = true; cfg.qaLabel = spec.label || cfg.profile
el.setAttribute('profile-config', JSON.stringify(cfg))
// ── PANE COLUMN vs SINGLE MOUNT ─────────────────────────────────────────
@ -1007,6 +1009,7 @@ async function mount(id){
if (SHELL_TOKEN) pcfg.tk = SHELL_TOKEN
pcfg.locale = locale
if (!pcfg.assetsBase) pcfg.assetsBase = new URL('assets', document.baseURI).href.replace(/\/+$/, '')
pcfg.qa = true; pcfg.qaLabel = spec.label || pcfg.profile
pel.setAttribute('profile-config', JSON.stringify(pcfg))
column.mountEl(pane.id)?.appendChild(pel)
extras.push(pel)

View file

@ -16,6 +16,7 @@
import { computed, defineAsyncComponent, provide, inject, watchEffect } from 'vue'
import { SUPPORTED_LOCALES } from './i18n/index.js'
import QaCorner from './components/qa/QaCorner.vue'
import { useUIStore } from './stores/uiStore.js'
import { useMapStore } from './stores/mapStore.js'
import { useDataStore } from './stores/dataStore.js'
@ -57,6 +58,9 @@ const mapboxToken = computed(() => config.value?.tk || '')
// host override via profile-config.dataSource. (doxa-csv stays the final offline fallback.)
const dataSource = computed(() => config.value?.dataSource || sourcesConfig.activeSource || 'doxa-csv')
const colorSet = computed(() => config.value?.colorSet || 'default')
// QA corner widget: OPT-IN via profile-config.qa (default OFF). On for the DOXA go-base
// surfaces (SPA + plugins); OFF for partner embeds so no QA button appears on their sites.
const showQa = computed(() => config.value?.qa === true)
// ─── Per-map language override (single keystone for ALL profiles) ─────────────
// `profile-config.locale` is the established contract — the host Nuxt page passes
@ -171,6 +175,9 @@ provide('dataStore', dataStore)
<!-- Render the matched profile -->
<component :is="ProfileComponent" v-else />
<!-- QA corner widget (opt-in via profile-config.qa) — floats over any profile -->
<QaCorner v-if="ProfileComponent && showQa" />
</div>
</template>

View file

@ -0,0 +1,93 @@
<script setup>
/**
* QaCorner.vue — the embeddable QA corner widget (bottom-right of any map).
*
* A small floating "QA" button that opens a panel to start/join a multiplayer QA
* session for THIS map. This is the visible shell of the QA-agent bridge (see
* _qa-cycle/01-ideation-research/qa-loops/qa-embeddable-qa-agent-bridge.md):
* - the panel is OIDC-gated onboarding ("Log in with Nuxtinator"), NEVER a key
* - a session is per-map, multiplayer (join code), agent-hosted rounds
* The backend (qhoot / nuxtbase-baas agent bridge + MCP) is wired in later; for
* now this renders the button + panel so it appears on every DOXA map.
*
* OPT-IN: only renders when profile-config carries `qa: true`. The same bundles
* power partner embeds, which must NOT show a QA button — so it is off by default
* and turned on only for the DOXA go-base surfaces (SPA + plugins).
*/
import { ref, inject, computed } from 'vue'
const uiStore = inject('uiStore', null)
const profileConfig = inject('profileConfig', null)
const isDark = computed(() => uiStore?.theme === 'dark')
// The map this session would be about — for the session label + later the bridge.
const mapLabel = computed(() =>
profileConfig?.value?.qaLabel || profileConfig?.value?.profile || 'this map'
)
const open = ref(false)
function toggle() { open.value = !open.value }
</script>
<template>
<div class="qa-corner" :class="{ dark: isDark }">
<!-- The floating button -->
<button
class="qa-fab"
type="button"
:aria-expanded="open"
aria-label="Open QA"
@click="toggle"
>
<span class="qa-dot" aria-hidden="true"></span>
QA
</button>
<!-- The panel (onboarding shell — backend wired later) -->
<div v-if="open" class="qa-panel" role="dialog" aria-label="QA session">
<div class="qa-head">
<strong>QA session</strong>
<button class="qa-x" type="button" aria-label="Close" @click="toggle">×</button>
</div>
<p class="qa-sub">Run a QA loop on <em>{{ mapLabel }}</em> with your team.</p>
<button class="qa-login" type="button" disabled title="Backend wiring in progress">
Log in with Nuxtinator
</button>
<p class="qa-note">Sign in to start or join a session. (Connecting to the QA bridge…)</p>
</div>
</div>
</template>
<style scoped>
.qa-corner { position: absolute; right: 14px; bottom: 14px; z-index: 40;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.qa-fab {
display: inline-flex; align-items: center; gap: 7px;
padding: 9px 15px; border: none; border-radius: 999px; cursor: pointer;
font-size: 13px; font-weight: 800; letter-spacing: .04em;
color: #fff; background: #1f2733;
box-shadow: 0 3px 12px rgba(0,0,0,.28);
transition: transform .12s ease, background .15s ease;
}
.qa-fab:hover { background: #000; transform: translateY(-1px); }
.qa-dot { width: 8px; height: 8px; border-radius: 50%; background: #37c871;
box-shadow: 0 0 0 3px rgba(55,200,113,.25); }
.qa-panel {
position: absolute; right: 0; bottom: 52px; width: 260px;
background: #fff; color: #1f2733; border: 1px solid #e2e6ec; border-radius: 12px;
padding: 14px; box-shadow: 0 8px 28px rgba(0,0,0,.22);
}
.qa-head { display: flex; align-items: center; justify-content: space-between; }
.qa-x { border: none; background: none; font-size: 20px; line-height: 1; cursor: pointer; color: #6b7686; }
.qa-sub { margin: 8px 0 12px; font-size: 12.5px; color: #4a5568; }
.qa-login { width: 100%; padding: 9px 12px; border: none; border-radius: 8px;
font-size: 13px; font-weight: 700; color: #fff; background: #e67e22; cursor: not-allowed; opacity: .85; }
.qa-note { margin: 10px 0 0; font-size: 11px; color: #8a94a3; }
/* dark */
.qa-corner.dark .qa-panel { background: #1b212b; color: #e7ebf0; border-color: #2c3542; }
.qa-corner.dark .qa-sub { color: #aeb7c2; }
.qa-corner.dark .qa-x { color: #aeb7c2; }
</style>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,122 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cities-list · key-cities</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself (design note: "Share shares THE MAP"). */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, key-cities-map { 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 token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <key-cities-map> custom element. -->
<script src="../key-cities.js"></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":"cities-list","dataSource":"pray-tools","instanceId":"key-cities-cities-list","assetsBase":"../../../assets"};
var TOKEN_SRC = "/api/maps/token.json";
var 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);
});
}
function done() { if (status && status.parentNode) { status.parentNode.removeChild(status); } }
function mount(token) {
if (!token) { return askToken(); }
try {
var cfg = Object.assign({}, BASE_CONFIG, { tk: token });
var el = document.createElement("key-cities-map");
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 (directToken) { return mount(directToken); }
// Rungs 4-6: resolve a token endpoint and fetch it.
var tokenUrl =
(window.MAP_TOKEN_URL) ||
(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '') ||
DEFAULT_TOKEN_URL;
fetch(tokenUrl)
.then(function (r) {
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) { mount(extractToken(data)); })
.catch(function (e) {
fail('Token fetch failed from ' + tokenUrl + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + (e && e.message ? e.message : e) + ')');
});
})();
</script>
</body>
</html>

View file

@ -1,122 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cities-map · key-cities</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself (design note: "Share shares THE MAP"). */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, key-cities-map { 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 token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <key-cities-map> custom element. -->
<script src="../key-cities.js"></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":"cities-map","dataSource":"pray-tools","instanceId":"key-cities-cities-map","assetsBase":"../../../assets"};
var TOKEN_SRC = "/api/maps/token.json";
var 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);
});
}
function done() { if (status && status.parentNode) { status.parentNode.removeChild(status); } }
function mount(token) {
if (!token) { return askToken(); }
try {
var cfg = Object.assign({}, BASE_CONFIG, { tk: token });
var el = document.createElement("key-cities-map");
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 (directToken) { return mount(directToken); }
// Rungs 4-6: resolve a token endpoint and fetch it.
var tokenUrl =
(window.MAP_TOKEN_URL) ||
(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '') ||
DEFAULT_TOKEN_URL;
fetch(tokenUrl)
.then(function (r) {
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) { mount(extractToken(data)); })
.catch(function (e) {
fail('Token fetch failed from ' + tokenUrl + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + (e && e.message ? e.message : e) + ')');
});
})();
</script>
</body>
</html>

View file

@ -1,122 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>city-poster-wall · key-cities</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself (design note: "Share shares THE MAP"). */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, key-cities-map { 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 token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <key-cities-map> custom element. -->
<script src="../key-cities.js"></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":"city-poster-wall","dataSource":"pray-tools","instanceId":"key-cities-city-poster-wall","assetsBase":"../../../assets"};
var TOKEN_SRC = "/api/maps/token.json";
var 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);
});
}
function done() { if (status && status.parentNode) { status.parentNode.removeChild(status); } }
function mount(token) {
if (!token) { return askToken(); }
try {
var cfg = Object.assign({}, BASE_CONFIG, { tk: token });
var el = document.createElement("key-cities-map");
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 (directToken) { return mount(directToken); }
// Rungs 4-6: resolve a token endpoint and fetch it.
var tokenUrl =
(window.MAP_TOKEN_URL) ||
(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '') ||
DEFAULT_TOKEN_URL;
fetch(tokenUrl)
.then(function (r) {
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) { mount(extractToken(data)); })
.catch(function (e) {
fail('Token fetch failed from ' + tokenUrl + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + (e && e.message ? e.message : e) + ')');
});
})();
</script>
</body>
</html>

View file

@ -1,122 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>data-tables · key-cities</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself (design note: "Share shares THE MAP"). */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, key-cities-map { 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 token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <key-cities-map> custom element. -->
<script src="../key-cities.js"></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":"data-tables","dataSource":"pray-tools","instanceId":"key-cities-data-tables","assetsBase":"../../../assets"};
var TOKEN_SRC = "/api/maps/token.json";
var 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);
});
}
function done() { if (status && status.parentNode) { status.parentNode.removeChild(status); } }
function mount(token) {
if (!token) { return askToken(); }
try {
var cfg = Object.assign({}, BASE_CONFIG, { tk: token });
var el = document.createElement("key-cities-map");
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 (directToken) { return mount(directToken); }
// Rungs 4-6: resolve a token endpoint and fetch it.
var tokenUrl =
(window.MAP_TOKEN_URL) ||
(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '') ||
DEFAULT_TOKEN_URL;
fetch(tokenUrl)
.then(function (r) {
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) { mount(extractToken(data)); })
.catch(function (e) {
fail('Token fetch failed from ' + tokenUrl + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + (e && e.message ? e.message : e) + ')');
});
})();
</script>
</body>
</html>

View file

@ -1,347 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-maps</title>
<link rel="icon" href="data:,">
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
--cy:#46d4ff; --vi:#b9a6f5; --gold:#ffc24b; --grn:#5ee0a0;
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background:
radial-gradient(1100px 480px at 82% -12%, rgba(70,212,255,.10), transparent 60%),
radial-gradient(820px 460px at -8% 112%, rgba(185,166,245,.10), transparent 60%),
var(--bg);
color: var(--ink); font-family: var(--sans); -webkit-font-smoothing: antialiased;
}
code { font-family: var(--mono); }
.hidden { display: none !important; }
.empty { color: var(--dim); font: 12px/1.5 var(--mono); padding: 18px; }
/* ── Shell: header strip, then sidebar|stage grid (desktop) ── */
header.top { padding: 14px 18px 10px; border-bottom: 1px solid var(--surf2); }
header.top h1 { font: 800 15px/1.2 var(--sans); letter-spacing: -.01em; }
header.top h1 .ac { color: var(--cy); font-weight: 600; }
header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut); }
.strip { padding: 7px 18px; font: 11px/1.4 var(--mono); color: var(--mut);
border-bottom: 1px solid var(--surf2); background: rgba(255,255,255,.012); }
.strip .pat { color: var(--cy); } .strip .err { color: #f87171; } .strip strong { color: var(--ink); }
/* token bar — visible only while no token resolves */
#tokenBar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
padding: 9px 18px; font: 12px/1.4 var(--sans); color: var(--gold);
border-bottom: 1px solid var(--surf2); background: rgba(255,194,75,.06); }
#tokenBar input { flex: 1 1 220px; max-width: 420px; font: 12px/1 var(--mono);
color: var(--ink); background: var(--surf2); border: 1px solid var(--line);
border-radius: 8px; padding: 8px 10px; }
#tokenBar button { font: 700 11px/1 var(--mono); color: #0a0e15; cursor: pointer;
background: linear-gradient(90deg, var(--cy), var(--vi)); border: 0;
border-radius: 999px; padding: 8px 14px; }
/* ── The one-page layout ── */
#shell { display: grid; grid-template-columns: minmax(280px, 340px) 1fr;
height: calc(100vh - 118px); min-height: 420px; }
/* sidebar: THE semantic tree — navigation and architecture in one */
#treeNav { overflow-y: auto; border-right: 1px solid var(--surf2);
padding: 12px 10px 28px; background: rgba(255,255,255,.008); }
.bgroup { margin-bottom: 8px; border: 1px solid var(--line); border-radius: 10px;
background: var(--surf); overflow: hidden; }
.bgroup summary { list-style: none; cursor: pointer; display: flex; align-items: baseline;
gap: 8px; padding: 10px 12px; font: 700 13px/1.2 var(--sans); }
.bgroup summary::-webkit-details-marker { display: none; }
.bgroup summary .bdot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto;
align-self: center; background: var(--gem, var(--cy)); box-shadow: 0 0 8px var(--gem, var(--cy)); }
.bgroup summary .bmeta { margin-left: auto; font: 10px/1.3 var(--mono); color: var(--dim); font-weight: 400; }
.bgroup[open] summary { border-bottom: 1px solid var(--surf2); }
.node { display: block; width: 100%; text-align: left; cursor: pointer; color: inherit;
background: transparent; border: 0; border-left: 2px solid transparent;
padding: 8px 12px 8px 22px; transition: background .12s; }
.node:hover { background: rgba(255,255,255,.03); }
.node.active { background: color-mix(in srgb, var(--gem, var(--cy)) 10%, transparent);
border-left-color: var(--gem, var(--cy)); }
.node .nlabel { display: block; font: 600 12.5px/1.3 var(--sans); }
.node .nsub { display: block; font: 10.5px/1.4 var(--mono); color: var(--mut); margin-top: 1px; }
.node .nedit { display: block; font: 9.5px/1.4 var(--mono); color: var(--dim); margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* kind glyphs */
.node.k-instance .nlabel::before { content: "◇ "; color: var(--grn); }
.node.k-profile .nlabel::before { content: "● "; color: var(--vi); }
.node.k-nested .nlabel::before { content: "◉ "; color: var(--gold); }
/* ── Stage: the ONE live map inside its GEM FRAME (the recurring hero theme) ── */
#stage { display: flex; flex-direction: column; min-width: 0; padding: 12px 16px 16px; }
.stage-head { display: flex; align-items: center; gap: 10px; padding: 0 2px 9px; }
.stage-head .sctag { font: 800 13px/1 var(--mono); color: var(--gem, var(--cy)); }
.stage-head .sckind { font: 8.5px/1 var(--mono); color: var(--dim); border: 1px solid var(--line);
border-radius: 999px; padding: 3px 8px; text-transform: uppercase; letter-spacing: .08em; }
.stage-head .popout { margin-left: auto; font: 600 11px/1 var(--mono); color: var(--mut);
text-decoration: none; border: 1px solid var(--line); border-radius: 999px; padding: 7px 12px; }
.stage-head .popout:hover { color: var(--ink); border-color: #3a4a63; }
/* the gem frame: colored jewel border + inner ring + soft glow in the node's gem color */
.map-slot { flex: 1 1 0; min-height: 0; position: relative; border-radius: 12px;
border: 1px solid var(--gem, var(--cy));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--gem, var(--cy)) 45%, transparent) inset,
0 0 26px rgba(var(--gem-rgb, 70,212,255), .16),
0 8px 30px rgba(0,0,0,.35);
background: #0d1622; overflow: hidden; padding: 8px; }
.map-slot iframe { position: absolute; inset: 8px; width: calc(100% - 16px);
height: calc(100% - 16px); border: 0; border-radius: 8px; }
/* ── Mobile: single column — tree on top, the map takes the FULL SCREEN below ──
(2026-07-11: mobile map = pretty much full screen; scroll must reach it.
100dvh = dynamic viewport height, tracks the mobile URL bar; 100vh = fallback.) */
@media (max-width: 899px) {
html, body { height: auto; } /* let the page actually scroll */
#shell { display: block; height: auto; }
#treeNav { border-right: 0; border-bottom: 1px solid var(--surf2); max-height: 44vh; }
#stage { height: 100vh; height: 100dvh; padding: 8px; } /* full-screen map pane */
.stage-head { padding-bottom: 6px; }
.map-slot { padding: 4px; border-radius: 10px; } /* slimmer gem frame = max map */
.map-slot iframe { inset: 4px; width: calc(100% - 8px); height: calc(100% - 8px); }
.node .nedit { display: none; } /* edit paths are a desktop affordance */
}
</style>
</head>
<body>
<header class="top">
<h1>doxa-maps<span class="ac"> / key-cities</span></h1>
<div class="sub">key-cities — one bundle, its profiles as paste-anywhere maps</div>
</header>
<div class="strip" id="frameInfo">loading…</div>
<div id="tokenBar" class="hidden"></div>
<div id="shell">
<nav id="treeNav" aria-label="semantic tree"></nav>
<main id="stage"></main>
</div>
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"key-cities","hrefBase":"../"};
/* file:// contract: manifest inlined at build — no fetch needed to render. */
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"app-profiles/*/*.vue","embedBase":".","bundleCount":10,"profileCount":21,"bundles":[{"bundle":"key-cities","scriptUrl":"./key-cities/key-cities.js","output":"doxa-maps/key-cities/key-cities.js","elements":["key-cities-map"],"profiles":[{"profile":"cities-list","file":"cities-list.vue","description":"","element":"key-cities-map","iframeUrl":"./key-cities/cities-list/index.html","scriptUrl":"./key-cities/key-cities.js","embed":{"iframe":"\u003ciframe src=\"./key-cities/cities-list/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./key-cities/key-cities.js\">\u003c/script>\n\u003ckey-cities-map profile-config='{\"profile\":\"cities-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/key-cities-map>"}},{"profile":"cities-map","file":"cities-map.vue","description":"","element":"key-cities-map","iframeUrl":"./key-cities/cities-map/index.html","scriptUrl":"./key-cities/key-cities.js","embed":{"iframe":"\u003ciframe src=\"./key-cities/cities-map/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./key-cities/key-cities.js\">\u003c/script>\n\u003ckey-cities-map profile-config='{\"profile\":\"cities-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/key-cities-map>"}},{"profile":"city-poster-wall","file":"city-poster-wall.vue","description":"","element":"key-cities-map","iframeUrl":"./key-cities/city-poster-wall/index.html","scriptUrl":"./key-cities/key-cities.js","embed":{"iframe":"\u003ciframe src=\"./key-cities/city-poster-wall/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./key-cities/key-cities.js\">\u003c/script>\n\u003ckey-cities-map profile-config='{\"profile\":\"city-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/key-cities-map>"}},{"profile":"data-tables","file":"data-tables.vue","description":"","element":"key-cities-map","iframeUrl":"./key-cities/data-tables/index.html","scriptUrl":"./key-cities/key-cities.js","embed":{"iframe":"\u003ciframe src=\"./key-cities/data-tables/index.html\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./key-cities/key-cities.js\">\u003c/script>\n\u003ckey-cities-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\"}'>\u003c/key-cities-map>"}}]}]};</script>
<script>/**
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
*
* design brief (2026-07-11, the merger of all prior staging files):
* - ONE page. No folder-hopping: the semantic tree IS the navigation.
* - Desktop: hierarchical tree SIDEBAR on the left (bundle → profile → instance,
* kind-badged, "where do I edit" hints) · selected map fills the stage on the right.
* - Mobile: tree on top, map below — scroll down to the map. Native responsive,
* no simulator toggle.
* - Token UX: resolve ?tk= → localStorage → none. If none, show a token bar
* (paste once, stored locally in the browser, never in files/links unless you
* use ?tk= on purpose). Maps must Just Work after one paste.
* - Auto-generated: whatever app-profiles exist, the tree detects and shows them.
*
* file:// contract: classic script, manifest inlined at build (window.FORGE.manifest),
* fetch only as dev fallback. No import/export. The DEV-HMR block at the bottom is
* stripped by generate-tree.mjs for built pages.
*/
/* global window, document */
const FORGE = window.FORGE || {}
const SCOPE = FORGE.scope || 'root'
const MODE = FORGE.mode || 'built'
const MANIFEST_URL = FORGE.manifestUrl || './manifest.json'
const HREF_BASE = FORGE.hrefBase || './'
const GEMS = ['#46d4ff', '#b9a6f5', '#ffc24b', '#5ee0a0', '#f97fb5', '#8ef0e4']
const $ = (sel) => document.querySelector(sel)
let tree = null
let flat = [] // flat list of selectable map nodes (profiles + instances)
let activeKey = null // selected node key
// ── Token resolution (maintainer ladder: ?tk= → localStorage → ask once) ─────────
const TK_STORE = 'MAPS_STAGING_TK'
function resolveToken() {
const qp = new URLSearchParams(location.search).get('tk')
if (qp) { try { localStorage.setItem(TK_STORE, qp) } catch (e) {} return qp }
try { return localStorage.getItem(TK_STORE) || '' } catch (e) { return '' }
}
let TK = resolveToken()
function withTk(href) {
if (!TK) return href
return href + (href.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TK)
}
// Carry the page's ?provider= into the embedded map iframe so the engine choice propagates.
function withProvider(href) {
const p = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('provider') : null
return p ? href + (href.includes('?') ? '&' : '?') + 'provider=' + encodeURIComponent(p) : href
}
// ── Load the semantic tree (inline-first; fetch = dev fallback) ──────────────
async function loadTree() {
if (FORGE.manifest) return FORGE.manifest
const res = await fetch(MANIFEST_URL, { cache: 'no-cache' })
if (!res.ok) throw new Error('manifest ' + res.status + ' from ' + MANIFEST_URL)
return res.json()
}
const treeHref = (rel) => HREF_BASE + rel
const hexRgb = (hex) => {
const h = hex.replace('#', '')
return parseInt(h.slice(0,2),16) + ',' + parseInt(h.slice(2,4),16) + ',' + parseInt(h.slice(4,6),16)
}
function profileKind(p) {
if (Array.isArray(p.instances) && p.instances.length) return 'parameterized'
if (p.nested || /nested|tabs|web components/i.test(p.description || '')) return 'nested'
return 'individual'
}
// ── Build the flat selectable list + the sidebar tree ────────────────────────
// Selectable = anything that renders as ONE map page: individual/nested profiles
// and each instance of a parameterized profile.
function buildFlat() {
flat = []
;(tree.bundles || []).forEach((b, bi) => { b.__gem = GEMS[bi % GEMS.length] })
for (const b of tree.bundles || []) {
for (const p of b.profiles || []) {
const kind = profileKind(p)
if (kind === 'parameterized') {
for (const inst of p.instances) {
flat.push({
key: b.bundle + '/' + p.profile + '/' + inst.id,
kind: 'instance', label: inst.title || inst.id,
sub: p.profile + ' · preset "' + inst.id + '"',
edit: b.bundle + '/' + p.profile + '.instances.json',
href: treeHref(b.bundle + '/' + p.profile + '/' + inst.id + '/index.html'),
bundle: b.bundle, element: p.element || (b.elements || [])[0], gem: b.__gem,
})
}
} else {
flat.push({
key: b.bundle + '/' + p.profile,
kind: kind === 'nested' ? 'nested' : 'profile',
label: p.profile,
sub: p.description || (kind === 'nested' ? 'embeds child web components' : 'one web component = one map'),
edit: 'app-profiles/' + b.bundle + '/' + (p.file || p.profile + '.vue'),
href: treeHref(b.bundle + '/' + p.profile + '/index.html'),
bundle: b.bundle, element: p.element || (b.elements || [])[0], gem: b.__gem,
})
}
}
}
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}
// Sidebar: bundles are <details> groups (open by default), each map node a row.
function renderSidebar() {
const el = $('#treeNav')
if (!el) return
const parts = []
const bundles = tree.bundles || []
bundles.forEach((b, bi) => {
const gem = b.__gem || GEMS[bi % GEMS.length]
const rows = flat.filter((n) => n.bundle === b.bundle).map((n) => `
<button class="node k-${n.kind}${n.key === activeKey ? ' active' : ''}" data-key="${esc(n.key)}" style="--gem:${gem}">
<span class="nlabel">${esc(n.label)}</span>
<span class="nsub">${esc(n.sub)}</span>
<code class="nedit">${esc(n.edit)}</code>
</button>`).join('')
parts.push(`
<details class="bgroup" open style="--gem:${gem}">
<summary><span class="bdot"></span>${esc(b.bundle)}
<span class="bmeta">${(b.profiles || []).length} profile${(b.profiles || []).length === 1 ? '' : 's'} · one .js</span>
</summary>
${rows}
</details>`)
})
el.innerHTML = parts.join('') || '<p class="empty">no bundles built yet</p>'
el.querySelectorAll('.node').forEach((btn) =>
btn.addEventListener('click', () => select(btn.dataset.key)))
}
// ── Stage: the ONE live map (iframe of the node's self-contained page) ───────
function renderStage() {
const st = $('#stage')
if (!st) return
const nd = flat.find((n) => n.key === activeKey) || flat[0]
if (!nd) { st.innerHTML = '<p class="empty">no maps in this tree</p>'; return }
activeKey = nd.key
const gem = nd.gem || '#46d4ff'
st.style.setProperty('--gem', gem)
st.style.setProperty('--gem-rgb', hexRgb(gem))
st.innerHTML = `
<div class="stage-head">
<span class="sctag">&lt;${esc(nd.element || nd.label)}&gt;</span>
<span class="sckind">${esc(nd.kind)}</span>
<a class="popout" href="${esc(withProvider(withTk(nd.href)))}" target="_blank" rel="noopener">open ↗</a>
</div>
<div class="map-slot"><iframe allowfullscreen src="${esc(withProvider(withTk(nd.href)))}"></iframe></div>`
// keep the sidebar highlight in sync
document.querySelectorAll('#treeNav .node').forEach((b) =>
b.classList.toggle('active', b.dataset.key === activeKey))
}
function select(key) {
activeKey = key
renderStage()
// Mobile: after picking from the tree, bring the map into view (scroll down to it).
if (window.matchMedia('(max-width: 899px)').matches) {
$('#stage')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
// ── Token bar: shown only while no token resolves; paste once, stored locally ─
function renderTokenBar() {
const bar = $('#tokenBar')
if (!bar) return
if (TK) { bar.classList.add('hidden'); return }
bar.classList.remove('hidden')
bar.innerHTML = `
<span>maps need a Mapbox token —</span>
<input id="tkInput" type="password" placeholder="paste pk.… (stays in this browser only)" autocomplete="off" />
<button id="tkSave">use it</button>`
$('#tkSave').addEventListener('click', () => {
const v = $('#tkInput').value.trim()
if (!v) return
TK = v
try { localStorage.setItem(TK_STORE, v) } catch (e) {}
bar.classList.add('hidden')
renderStage()
})
}
// ── Boot ─────────────────────────────────────────────────────────────────────
async function boot() {
const info = $('#frameInfo')
try {
tree = await loadTree()
buildFlat()
if (info) {
info.innerHTML = '<span class="pat">semantic tree</span> — '
+ (tree.bundles || []).length + ' bundle(s) · ' + flat.length + ' map(s) · '
+ '<strong>' + esc(MODE) + '</strong>'
}
renderSidebar()
renderTokenBar()
if (flat.length) { activeKey = flat[0].key; renderStage() }
} catch (e) {
if (info) info.innerHTML = '<span class="err">failed to load semantic tree: ' + esc(e.message) + '</span>'
}
}
boot()
/* dev-hmr stripped for built pages */
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -984,6 +984,8 @@ async function mount(id){
// a subpath (e.g. /plugin-assets/doxa-maps-spa/), a bare '/assets' or './assets' misresolves,
// so hand each bundle an ABSOLUTE base derived from THIS shell's own document — correct at any depth.
if (!cfg.assetsBase) cfg.assetsBase = new URL('assets', document.baseURI).href.replace(/\/+$/, '')
// QA corner widget ON for the go-base SPA surface (opt-in flag; off for partner embeds).
cfg.qa = true; cfg.qaLabel = spec.label || cfg.profile
el.setAttribute('profile-config', JSON.stringify(cfg))
// ── PANE COLUMN vs SINGLE MOUNT ─────────────────────────────────────────
@ -1007,6 +1009,7 @@ async function mount(id){
if (SHELL_TOKEN) pcfg.tk = SHELL_TOKEN
pcfg.locale = locale
if (!pcfg.assetsBase) pcfg.assetsBase = new URL('assets', document.baseURI).href.replace(/\/+$/, '')
pcfg.qa = true; pcfg.qaLabel = spec.label || pcfg.profile
pel.setAttribute('profile-config', JSON.stringify(pcfg))
column.mountEl(pane.id)?.appendChild(pel)
extras.push(pel)