feat(qa): QA corner widget on maps (opt-in), on for the DOXA SPA surface
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:
parent
63bc1d77c1
commit
9ab4ea2d44
19 changed files with 361 additions and 1090 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
93
library/components/qa/QaCorner.vue
Normal file
93
library/components/qa/QaCorner.vue
Normal 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>
|
||||
30
public/doxa-maps/cities-internet/cities-internet.js
generated
30
public/doxa-maps/cities-internet/cities-internet.js
generated
File diff suppressed because one or more lines are too long
86
public/doxa-maps/country-posters/country-posters.js
generated
86
public/doxa-maps/country-posters/country-posters.js
generated
File diff suppressed because one or more lines are too long
64
public/doxa-maps/demo-map/demo-map.js
generated
64
public/doxa-maps/demo-map/demo-map.js
generated
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
76
public/doxa-maps/doxa-simple-map/doxa-simple-map.js
generated
76
public/doxa-maps/doxa-simple-map/doxa-simple-map.js
generated
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
122
public/doxa-maps/key-cities/cities-list/index.html
generated
122
public/doxa-maps/key-cities/cities-list/index.html
generated
|
|
@ -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>
|
||||
122
public/doxa-maps/key-cities/cities-map/index.html
generated
122
public/doxa-maps/key-cities/cities-map/index.html
generated
|
|
@ -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>
|
||||
122
public/doxa-maps/key-cities/city-poster-wall/index.html
generated
122
public/doxa-maps/key-cities/city-poster-wall/index.html
generated
|
|
@ -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>
|
||||
122
public/doxa-maps/key-cities/data-tables/index.html
generated
122
public/doxa-maps/key-cities/data-tables/index.html
generated
|
|
@ -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>
|
||||
347
public/doxa-maps/key-cities/index.html
generated
347
public/doxa-maps/key-cities/index.html
generated
|
|
@ -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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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"><${esc(nd.element || nd.label)}></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>
|
||||
30
public/doxa-maps/key-cities/key-cities.js
generated
30
public/doxa-maps/key-cities/key-cities.js
generated
File diff suppressed because one or more lines are too long
42
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
42
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
30
public/doxa-maps/template-bundle/template-bundle.js
generated
30
public/doxa-maps/template-bundle/template-bundle.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue