feat: map provider swap — mapbox|maplibre engine + hybrid geocoder + provider badge; staging title doxa-maps

This commit is contained in:
doxa-maps 2026-09-10 20:03:24 +00:00
parent 0f38a5448c
commit c7260faaa3
25 changed files with 589 additions and 307 deletions

View file

@ -191,15 +191,23 @@
// with the value at request time; the .env file is gitignored.
window.DOXA_TOKEN = '%VITE_MAPBOX_TOKEN%';
// Map engine override for slots WITHOUT an explicit config.provider:
// ?provider=maplibre flips the default engine for those slots. A slot that
// sets config.provider always wins (per-map). Default stays 'mapbox'.
window.MAP_PROVIDER = new URLSearchParams(location.search).get('provider') || undefined;
window.DOXA_SLOTS = [
// ── research-map: full 4-tab build (Prayer / Engagement / Adoption / Language Families) ──
{ profile: 'research-map', label: 'Research — pray-tools', gem: 'amethyst',
config: { dataSource: 'pray-tools', instanceId: 'dev-research-1' } },
// Slot 1 renders with the default engine (Mapbox, unless ?provider= flips it).
{ profile: 'research-map', label: 'Research — Mapbox engine', gem: 'amethyst',
config: { dataSource: 'pray-tools', instanceId: 'dev-research-1', provider: 'mapbox' } },
{ profile: 'research-map', label: 'Research — second instance', gem: 'sapphire',
config: { dataSource: 'pray-tools', instanceId: 'dev-research-2' } },
// Slot 2 is pinned to MapLibre so both engines render side-by-side on one page.
{ profile: 'research-map', label: 'Research — MapLibre engine', gem: 'emerald',
config: { dataSource: 'pray-tools', instanceId: 'dev-research-2', provider: 'maplibre' } },
{ profile: 'research-map', label: 'Research — third instance', gem: 'emerald',
// Slot 3 follows the page default (?provider= or Mapbox).
{ profile: 'research-map', label: 'Research — default engine', gem: 'sapphire',
config: { dataSource: 'pray-tools', instanceId: 'dev-research-3' } }
];
</script>

View file

@ -32,6 +32,7 @@ import { inject, provide, ref, computed, onMounted, onBeforeUnmount, watch, mark
// ─── Composables (framework) ─────────────────────────────────────────────────
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { resolveMapProvider, providerLabel } from '@map/composables/useMapProvider.js'
import { useMapLayers, PIN_STROKE_WIDTH_LIGHT, buildDarkRingWidth } from '@map/composables/useMapLayers.js'
import { useMapTheme, THEME_TOGGLE, FIXED_BASEMAP } from '@map/composables/useMapTheme.js'
import { useMapEvents } from '@map/composables/useMapEvents.js'
@ -322,6 +323,11 @@ useShadowStyles(`
failure reads as "slow" and the user just waits; this says the map is gone. */
.rm-error { position:absolute;top:50%;left:12px;right:12px;z-index:7;transform:translateY(-50%);text-align:center;font:13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#fecaca;background:rgba(15,18,22,.95);border:1px solid rgba(254,202,202,.28);border-radius:10px;padding:12px 14px;box-shadow:0 2px 10px rgba(0,0,0,.4); }
.rm-badge { position:absolute;top:10px;right:10px;background:rgba(124,140,248,0.12);color:#7c8cf8;border:1px solid rgba(124,140,248,0.3);padding:3px 9px;border-radius:4px;font-size:11px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;pointer-events:none;z-index:50; }
/* Engine badge — bottom-left, unobtrusive. Colour differs per engine so a
side-by-side comparison is readable at a glance. */
.rm-provider-badge { position:absolute;bottom:8px;left:8px;z-index:50;pointer-events:none;padding:2px 8px;border-radius:999px;font:600 10px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;letter-spacing:.04em;color:#fff;background:rgba(30,41,59,0.72);border:1px solid rgba(255,255,255,0.18);backdrop-filter:blur(2px); }
.rm-provider-badge[data-provider="mapbox"] { background:rgba(37,99,235,0.78);border-color:rgba(147,197,253,0.5); }
.rm-provider-badge[data-provider="maplibre"] { background:rgba(5,150,105,0.80);border-color:rgba(110,231,183,0.5); }
/* Mobile-only legend slot — desktop hides via @media */
.rm-legend-mobile-slot { display:none; }
@media(max-width:767px){
@ -336,6 +342,9 @@ useShadowStyles(`
// ─── Config from ProfileLoader (provide/inject — never direct imports) ──────
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
// Map engine: profile-config.provider → window.MAP_PROVIDER → 'mapbox' (default).
const mapProvider = resolveMapProvider(profileConfig)
const mapProviderName = providerLabel(mapProvider)
const dataSource = inject('dataSource')
const colorSet = inject('colorSet')
const injectedInstanceId = inject('instanceId', null)
@ -516,6 +525,7 @@ const RESEARCH_MAX_ZOOM = 22
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
provider: mapProvider, // 'mapbox' (default) | 'maplibre'
style: profileConfig?.value?.style ?? mapTheme.bootStyle(), // persisted-theme boot (shared)
center: profileConfig?.value?.center ?? mapDefaults.center ?? [20, 10],
zoom: profileConfig?.value?.zoom ?? mapDefaults.zoom ?? 1.8,
@ -2016,6 +2026,9 @@ onBeforeUnmount(() => {
<div class="map-scroll-edge map-scroll-edge--left" />
<div class="map-scroll-edge map-scroll-edge--right" />
<!-- Engine badge (bottom-left) — shows which GL engine rendered this map. -->
<div class="rm-provider-badge" :data-provider="mapProvider">{{ mapProviderName }}</div>
<!-- "RESEARCH" corner badge removed per qa.md R5 status flag (2026-04-27).
Style class .rm-badge kept in shadow CSS in case future tabs want it. -->
@ -2026,6 +2039,7 @@ onBeforeUnmount(() => {
v-if="appReady && map"
:map-instance="map"
:access-token="mapboxToken"
:provider="mapProvider"
:data-source-id="dataSource"
:is-dark="isDark"
:placeholder="geocoderPlaceholder"

BIN
bun.lock generated

Binary file not shown.

View file

@ -91,7 +91,7 @@ function assetsBaseFor(depth) {
// title is the PRODUCT title (config displayTitle, default org-free), never the deployment TREE_NAME
// (TREE_NAME stays in URLs/paths where it is a folder name, not branding).
const DISPLAY_TITLE = (typeof DEST_CONFIG.displayTitle === 'string' && DEST_CONFIG.displayTitle.trim())
? DEST_CONFIG.displayTitle.trim() : 'Embeddable JAMstack Maps'
? DEST_CONFIG.displayTitle.trim() : 'doxa-maps'
// Base URL every intra-tree ref (manifest iframeUrl/scriptUrl) is built against.
// With embedBaseUrl set (e.g. "https://doxa.life/js"), refs are ABSOLUTE — the
// build-time override for known hosts / partner embeds. WITHOUT it, refs are
@ -233,7 +233,7 @@ header.forge .crumb a { color: var(--cy); }
const GEMS = ['#46d4ff', '#b9a6f5', '#5ee0a0', '#ffc24b', '#f472b6', '#f59e0b', '#34d399']
const hexRgb = (h) => { const n = parseInt(h.slice(1), 16); return (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255) }
// ── The "Embeddable JAMstack Maps" staging shell ────────────────────────────────────────────
// ── The "doxa-maps" staging shell ────────────────────────────────────────────
// ONE staging design, emitted at every tree level (root + each bundle). It is a
// thin HTML shell that loads the shared, org-agnostic renderer (staging-forge.js,
// inlined at build time below) with a `window.FORGE` config telling it which tree
@ -253,7 +253,7 @@ function readForgeAsset(rel) {
}
/**
* The shared Embeddable JAMstack Maps staging page. Self-contained: CSS + renderer are
* The shared doxa-maps staging page. Self-contained: CSS + renderer are
* INLINED (no external fetch that could 404 on a static host), except the
* manifest which the renderer fetches relative to the page.
*
@ -312,7 +312,7 @@ function forgeHtml(o) {
// ── Page templates ───────────────────────────────────────────────────────────
/** Root staging page — the Embeddable JAMstack Maps showing ALL bundles (scope='root'). */
/** Root staging page — the doxa-maps showing ALL bundles (scope='root'). */
function rootHtml(bundles, manifest) {
return forgeHtml({
inlineManifest: manifest,
@ -322,13 +322,13 @@ function rootHtml(bundles, manifest) {
})
}
/** Bundle staging page — the Embeddable JAMstack Maps showing THIS bundle's profiles (scope='bundle'). */
/** Bundle staging page — the doxa-maps showing THIS bundle's profiles (scope='bundle'). */
function bundleHtml(b, index, manifest) {
return forgeHtml({
inlineManifest: manifest,
scope: 'bundle',
bundle: b.bundle,
title: `${DISPLAY_TITLE} · ${b.bundle}`,
title: `${DISPLAY_TITLE}`,
subtitle: `${b.bundle} — one bundle, its profiles as paste-anywhere maps`,
crumbHref: '../index.html',
crumbLabel: 'all bundles',

View file

@ -1,7 +1,7 @@
/* 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. Embeddable JAMstack Maps design language. */
architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;

View file

@ -1,4 +1,4 @@
// staging.js — doxa-maps "Embeddable JAMstack Maps" staging showcase (dev only, no build step).
// staging.js — doxa-maps "doxa-maps" staging showcase (dev only, no build step).
//
// UX: the header showcases the single-build-process architecture (one
// shared core → one build → JS bundles → element tags → maps). A horizontal-scroll strip lists

View file

@ -67,17 +67,24 @@ import { useI18n } from 'vue-i18n'
import { useDoxaSearch } from '../../composables/useDoxaSearch.js'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
// The Mapbox and MapLibre geocoders emit identical DOM under different class
// prefixes (.mapboxgl-ctrl-geocoder* vs .maplibregl-ctrl-geocoder*). dualEngine
// duplicates every rule for both so one skin covers whichever engine is active.
function dualEngine(css) {
return css + css.replace(/mapboxgl/g, 'maplibregl')
}
// iOS Safari auto-zooms the page whenever a text input with computed
// font-size < 16px receives focus, and the "Done" keyboard button re-fires
// the same zoom on blur. We override the Mapbox geocoder input to 16px in
// the shadow root so the auto-zoom never triggers. 16px is the documented
// threshold; setting it explicitly here also stops the page from getting
// stuck zoomed-in on devices that disallow pinch-zoom recovery.
useShadowStyles(
useShadowStyles(dualEngine(
'.mapboxgl-ctrl-geocoder--input,'
+ '.mapboxgl-ctrl-geocoder input[type="text"],'
+ '.mapboxgl-ctrl-geocoder input[type="search"]'
+ '{font-size:16px !important;line-height:1.3;}',
+ '{font-size:16px !important;line-height:1.3;}'),
'geocoder-ios-zoom-fix'
)
@ -85,8 +92,8 @@ useShadowStyles(
// slot but is NOT vertically centered with the clear (×) button, so it reads as a
// visual glitch while a query is in flight. The local search is fast enough that
// the spinner adds no value (coder: "the loading indicator needs to not show up").
useShadowStyles(
'.mapboxgl-ctrl-geocoder--icon-loading{display:none !important;}',
useShadowStyles(dualEngine(
'.mapboxgl-ctrl-geocoder--icon-loading{display:none !important;}'),
'geocoder-hide-loading'
)
@ -98,11 +105,11 @@ useShadowStyles(
// search icon is re-centered for the shorter box (the clear/× button is already centered
// at top:50% by each profile's base rule). Shared here so every profile inherits it —
// this REPLACES the per-profile @media mobile-height hack (bug-searchbar-mobile-height).
useShadowStyles(
useShadowStyles(dualEngine(
'.mapboxgl-ctrl-geocoder{height:35px!important;min-height:35px!important;box-sizing:border-box!important;}'
+ '.mapboxgl-ctrl-geocoder--input{height:35px!important;min-height:35px!important;box-sizing:border-box!important;'
+ 'padding-top:0!important;padding-bottom:0!important;line-height:35px!important;}'
+ '.mapboxgl-ctrl-geocoder--icon-search{top:50%!important;transform:translateY(-50%)!important;margin-top:0!important;left:7px!important;}',
+ '.mapboxgl-ctrl-geocoder--icon-search{top:50%!important;transform:translateY(-50%)!important;margin-top:0!important;left:7px!important;}'),
'geocoder-height-35'
)
@ -119,7 +126,7 @@ useShadowStyles(
// layout); they must not re-declare colour, radius, shadow or focus ring.
//
// Palette is research-map's — the reference implementation.
useShadowStyles(
useShadowStyles(dualEngine(
// FILL — the pill always fills whatever container it is placed in, and the
// container is what a profile sizes. This is the shared search-bar CONTRACT, not
// a per-map preference: every map wants the pill to fill its slot. Sizing the
@ -161,7 +168,7 @@ useShadowStyles(
+ 'color:rgba(243,243,241,0.65)!important;}'
+ '.geocoder-dark .mapboxgl-ctrl-geocoder--icon,.dsm-dark .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--icon{'
+ 'fill:rgba(243,243,241,0.75)!important;}'
+ '.geocoder-dark .mapboxgl-ctrl-geocoder--button,.dsm-dark .mapboxgl-ctrl-geocoder--button{background:transparent!important;}',
+ '.geocoder-dark .mapboxgl-ctrl-geocoder--button,.dsm-dark .mapboxgl-ctrl-geocoder--button{background:transparent!important;}'),
'geocoder-skin'
)
@ -174,6 +181,12 @@ const props = defineProps({
// pass a live instance, so their behavior is unchanged.
mapInstance: { type: Object, required: false, default: null },
accessToken: { type: String, required: true },
/**
* Map engine: 'mapbox' (default — MapboxGeocoder) | 'maplibre'
* (MaplibreGeocoder + a Mapbox REST forwardGeocode, so place search still
* works on a MapLibre map). The local people-group geocoder is shared.
*/
provider: { type: String, default: 'mapbox' },
isDark: { type: Boolean, default: false },
/**
* When true, render the geocoder input into THIS component's own container
@ -351,8 +364,17 @@ const standaloneContainer = ref(null)
// The geocoder stylesheet must live inside the shadow root; injecting it into
// the document head has no effect because the widget renders inside a
// ShadowRoot. We target the closest ShadowRoot of the map container.
function injectGeocoderCSS(shadowRoot) {
const HREF = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css'
const GEOCODER_CSS = {
mapbox: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css',
maplibre: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.css',
}
// MaplibreGeocoder ships as a CDN global (UMD). Mapbox's is preloaded via
// index.html; MapLibre's is injected on demand so a profile can flip provider
// with no host change.
const MAPLIBRE_GEOCODER_JS = 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.min.js'
function injectGeocoderCSS(shadowRoot, prov) {
const HREF = GEOCODER_CSS[prov] || GEOCODER_CSS.mapbox
if (shadowRoot.querySelector(`link[href="${HREF}"]`)) return // already injected
const link = document.createElement('link')
link.rel = 'stylesheet'
@ -360,9 +382,72 @@ function injectGeocoderCSS(shadowRoot) {
shadowRoot.prepend(link)
}
/** Load the MaplibreGeocoder UMD global on demand (idempotent). */
async function ensureMaplibreGeocoder() {
if (typeof window !== 'undefined' && window.MaplibreGeocoder) return
if (typeof document === 'undefined') return
const existing = document.querySelector(`script[src="${MAPLIBRE_GEOCODER_JS}"]`)
if (existing) {
if (window.MaplibreGeocoder) return
await new Promise(resolve => {
existing.addEventListener('load', resolve, { once: true })
existing.addEventListener('error', resolve, { once: true })
})
return
}
await new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = MAPLIBRE_GEOCODER_JS
s.async = false
s.addEventListener('load', resolve, { once: true })
s.addEventListener('error', () => reject(new Error('[GeocoderComponent] Failed to load MapLibre geocoder')), { once: true })
document.head.appendChild(s)
})
}
/**
* Builds a MaplibreGeocoder `geocoderApi.forwardGeocode` backed by the Mapbox
* Geocoding REST API — so place search still works on a MapLibre map (hybrid:
* MapLibre basemap + Mapbox geocoding). Returns Carmen GeoJSON, which the
* geocoder consumes directly. Empty result (no token / network error) leaves the
* local people-group search fully functional.
*/
function makeMapboxForwardGeocode(token, getLang) {
return async (config) => {
const empty = { features: [] }
if (!token || !config?.query) return empty
try {
const lang = Array.isArray(config.language) ? config.language[0] : (config.language || getLang() || 'en')
const params = new URLSearchParams({
access_token: token,
autocomplete: 'true',
limit: String(config.limit || 5),
})
if (lang) params.set('language', lang)
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(config.query)}.json?${params.toString()}`
const res = await fetch(url)
if (!res.ok) return empty
const geojson = await res.json()
return (geojson && Array.isArray(geojson.features)) ? geojson : empty
} catch (e) {
return empty
}
}
}
// ── Geocoder lifecycle ────────────────────────────────────────────────────────
onMounted(() => {
if (typeof MapboxGeocoder === 'undefined') {
onMounted(async () => {
const isMaplibre = props.provider === 'maplibre'
// Ensure the right geocoder global is present. Mapbox's is preloaded via
// index.html; MapLibre's is injected on demand.
if (isMaplibre) {
await ensureMaplibreGeocoder()
if (typeof MaplibreGeocoder === 'undefined') {
console.error('[GeocoderComponent] MaplibreGeocoder global unavailable.')
return
}
} else if (typeof MapboxGeocoder === 'undefined') {
return
}
@ -378,7 +463,7 @@ onMounted(() => {
? standaloneContainer.value?.getRootNode?.()
: map.getContainer?.()?.getRootNode?.()
if (shadowRoot instanceof ShadowRoot) {
injectGeocoderCSS(shadowRoot)
injectGeocoderCSS(shadowRoot, props.provider)
}
// Create geocoder instance.
@ -393,11 +478,11 @@ onMounted(() => {
// category tag the `render` callback (renderSuggestion) prepends.
//
// `localGeocoderOnly` is intentionally LEFT OUT — we want BOTH sources
// (Mapbox places AND DOXA records) visible in the suggestions list.
// eslint-disable-next-line no-undef
geocoder.value = new MapboxGeocoder({
accessToken: props.accessToken,
mapboxgl: mapboxgl, // CDN global — same reference used by the map instance
// (place results AND DOXA records) visible in the suggestions list.
//
// Shared options — the two geocoders share almost the entire option surface;
// only the constructor shape + the geocoding backend differ (see below).
const sharedOpts = {
marker: false,
// Standalone (header) bars are search-only: results drive the selection bus,
// not a map flyTo — so disable flyTo (no map). Map-control mode keeps the
@ -409,24 +494,42 @@ onMounted(() => {
// mode keeps the default (byte-identical: !standalone === true).
trackProximity: !props.standalone,
placeholder: effectivePlaceholder.value,
// Localize Mapbox's remote place results AND the geocoder's own UI strings.
// Mapbox accepts ISO-639-1 codes (comma-separated); the active i18n locale
// is set once at mount from profile-config.lang (default 'en'). See loc-002.
// Localize remote place results AND the geocoder's own UI strings. Mapbox
// accepts ISO-639-1 codes; the active i18n locale is set once at mount from
// profile-config.lang (default 'en'). See loc-002.
language: locale.value || 'en',
localGeocoder: doxaLocalGeocoder,
// Raise suggestion limit well above Mapbox's default (5) so a query like
// "India" can surface many people-groups. CSS caps the dropdown height
// and adds overflow-y:auto so the user can scroll through them.
// Raise suggestion limit well above the default (5) so a query like "India"
// can surface many people-groups. CSS caps the dropdown height + overflow.
limit: 20,
// Ensure our local results show even when Mapbox has remote matches.
// (Some versions default to localGeocoder results appearing AFTER the
// remote ones; keeping default order is fine — the category tag makes
// our rows unambiguous.)
zoom: 6,
// Custom row renderer — injects structured, bold-labeled metadata
// for DOXA results. See renderSuggestion() above.
render: renderSuggestion
})
// Custom row renderer — injects structured, bold-labeled metadata for DOXA
// results. See renderSuggestion() above.
render: renderSuggestion,
}
if (isMaplibre) {
// MapLibre map + Mapbox geocoding (hybrid). forwardGeocode hits the Mapbox
// REST API with the same public token; the local people-group search is
// unchanged. Requires the geocoderApi as the FIRST constructor arg.
// eslint-disable-next-line no-undef
const gl = typeof maplibregl !== 'undefined' ? maplibregl : undefined
const geocoderApi = { forwardGeocode: makeMapboxForwardGeocode(props.accessToken, () => locale.value) }
// eslint-disable-next-line no-undef
geocoder.value = new MaplibreGeocoder(geocoderApi, {
...sharedOpts,
maplibregl: gl,
showResultsWhileTyping: true,
reverseGeocode: false,
})
} else {
// eslint-disable-next-line no-undef
geocoder.value = new MapboxGeocoder({
accessToken: props.accessToken,
mapboxgl: mapboxgl, // CDN global — same reference used by the map instance
...sharedOpts,
})
}
// Forward geocoder events as Vue emits. When a DOXA people-group result
// is chosen we additionally emit `people-group-result` carrying the

View file

@ -28,24 +28,87 @@
import { ref } from 'vue'
import { useEdgeScrollAffordance } from './useEdgeScrollAffordance.js'
const MAPBOX_CSS_URL = 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css'
// ─── Engine registry (provider → CDN script + css + global) ──────────────────
// The map engine is loaded as a page global (CDN <script>). Profiles / index.html
// may preload it; if absent we inject it here. Mapbox is the default — its URLs
// match the existing <script> tags so nothing double-loads or changes.
const ENGINES = {
mapbox: {
global: 'mapboxgl',
js: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js',
css: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css',
},
maplibre: {
global: 'maplibregl',
js: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js',
css: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css',
},
}
const MAPBOX_CSS_URL = ENGINES.mapbox.css
// Free basemap for MapLibre (no token). Deliberately NOT the hand-tuned Mapbox
// style — see the provider notes; a MapLibre map looks different by design.
const OPENFREEMAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
/** The live engine global for a provider, or undefined if not yet loaded. */
function engineGlobal(provider) {
const name = ENGINES[provider]?.global || ENGINES.mapbox.global
return typeof window !== 'undefined' ? window[name] : undefined
}
/**
* Injects the Mapbox GL CSS into the shadow root that contains the given
* Ensures the engine's CDN <script> (+ head css) is loaded for the provider.
* Idempotent — resolves immediately if the global already exists (preloaded via
* index.html), otherwise injects the script and awaits its load event.
*/
async function ensureEngineScript(provider) {
const cfg = ENGINES[provider] || ENGINES.mapbox
if (engineGlobal(provider)) return // already loaded — no-op
if (typeof document === 'undefined') return
if (!document.querySelector(`link[href="${cfg.css}"]`)) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = cfg.css
document.head.appendChild(link)
}
const existing = document.querySelector(`script[src="${cfg.js}"]`)
if (existing) {
if (engineGlobal(provider)) return
await new Promise(resolve => {
existing.addEventListener('load', resolve, { once: true })
existing.addEventListener('error', resolve, { once: true })
})
return
}
await new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = cfg.js
s.async = false
s.addEventListener('load', resolve, { once: true })
s.addEventListener('error', () => reject(new Error(`[useMapInstance] Failed to load ${provider} engine script`)), { once: true })
document.head.appendChild(s)
})
}
/**
* Injects the engine's GL CSS into the shadow root that contains the given
* element, then waits for it to finish loading.
*
* - Skips if the element is in the regular document (no shadow root).
* - Skips if the CSS link is already present and loaded (idempotent).
* - Always resolves — never hangs (2 s timeout fallback).
*/
async function ensureMapboxCSS(element) {
async function ensureEngineCSS(element, cssUrl) {
const root = element.getRootNode()
if (!(root instanceof ShadowRoot)) return // light DOM — wrapper <link> is enough
const timeout = (ms) => new Promise(resolve => setTimeout(resolve, ms))
// Already present — check if loaded via `sheet` or wait for event
const existing = root.querySelector(`link[href="${MAPBOX_CSS_URL}"]`)
const existing = root.querySelector(`link[href="${cssUrl}"]`)
if (existing) {
// `sheet` is set synchronously once the stylesheet is parsed — reliable check
if (existing.sheet) return
@ -65,7 +128,7 @@ async function ensureMapboxCSS(element) {
// so we cannot miss the load event even if the browser resolves it synchronously
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = MAPBOX_CSS_URL
link.href = cssUrl
await Promise.race([
new Promise(resolve => {
@ -77,6 +140,15 @@ async function ensureMapboxCSS(element) {
])
}
/**
* Effective style for the MapLibre engine. A mapbox:// style needs a token and
* the Mapbox engine, so it is ignored here — an explicit http(s) style URL is
* honored, otherwise the free OpenFreeMap basemap is used.
*/
function maplibreStyle(style) {
return (typeof style === 'string' && /^https?:\/\//.test(style)) ? style : OPENFREEMAP_STYLE
}
// ─── Aspect-aware zoom-out floor (Feedback #2+#4 REDO) ───────────────────────
// We KEEP renderWorldCopies:true (infinite horizontal scroll) and instead cap
// zoom-out with a DYNAMIC minZoom floor computed from the viewport. The floor
@ -127,6 +199,8 @@ export function useMapInstance({
containerRef,
accessToken,
style,
// provider: 'mapbox' (default — unchanged) | 'maplibre'. Selects the GL engine.
provider = 'mapbox',
center = [20, 10],
zoom = 2,
// minZoom: a profile may PIN a value (e.g. 0.5). It is treated as a LOWER
@ -159,29 +233,38 @@ export function useMapInstance({
// Holds the gray side-edge touch handlers so destroy() can remove them.
let _detachEdgeBorders = null
const isMaplibre = provider === 'maplibre'
async function initializeMap() {
if (typeof mapboxgl === 'undefined') {
console.error('mapboxgl is not available. Add the Mapbox GL JS script to your page.')
return
}
if (!containerRef.value) {
console.error('containerRef.value is null — call initializeMap() inside onMounted()')
return
}
// ─── API Key validation ──────────────────────────────────────────
if (!accessToken) {
console.error('No Mapbox access token provided. Pass "tk" in profile-config JSON.')
return
}
if (!accessToken.startsWith('pk.')) {
console.error('Mapbox token looks invalid (expected "pk." prefix). Got:', accessToken.slice(0, 10) + '...')
// Load the selected engine (no-op if index.html preloaded it).
await ensureEngineScript(provider)
const gl = engineGlobal(provider)
if (!gl) {
console.error(`[useMapInstance] ${isMaplibre ? 'maplibregl' : 'mapboxgl'} is not available. `
+ `Add the ${isMaplibre ? 'MapLibre' : 'Mapbox'} GL JS script to your page.`)
return
}
// Wait for Mapbox CSS inside the shadow root before creating the Map.
// ─── API Key validation (Mapbox only — MapLibre + OpenFreeMap need no token) ──
if (!isMaplibre) {
if (!accessToken) {
console.error('No Mapbox access token provided. Pass "tk" in profile-config JSON.')
return
}
if (!accessToken.startsWith('pk.')) {
console.error('Mapbox token looks invalid (expected "pk." prefix). Got:', accessToken.slice(0, 10) + '...')
return
}
}
// Wait for the engine CSS inside the shadow root before creating the Map.
// Without this, _detectMissingCSS fires and the canvas initializes at 0px.
await ensureMapboxCSS(containerRef.value)
await ensureEngineCSS(containerRef.value, isMaplibre ? ENGINES.maplibre.css : MAPBOX_CSS_URL)
// Wait one animation frame so the browser has laid out the container.
// Mapbox reads clientWidth/clientHeight at init — if layout hasn't settled
@ -207,7 +290,7 @@ export function useMapInstance({
// See intel/discovery-reports/W1A-mapbox-create-web-app-bp.md for rationale.
// Rename deferred: MFEs reference the current name; needs a coordinated update.
const token = accessToken
mapboxgl.accessToken = token
if (!isMaplibre) gl.accessToken = token
// ─── Effective zoom-out floor ────────────────────────────────────
// floor = the aspect-aware no-duplicate-world minZoom. A profile-pinned
@ -228,31 +311,51 @@ export function useMapInstance({
}
const initialMinZoom = floorFor(el.clientWidth, el.clientHeight)
map.value = new mapboxgl.Map({
accessToken: token, // BP #1 from W1A — per-instance token; avoids last-writer-wins on multi-embed pages
container: containerRef.value, // real DOM element — never a string ID in shadow DOM
style,
center,
zoom,
minZoom: initialMinZoom,
maxZoom,
pitch,
bearing,
dragRotate: false, // disorienting north-drift: no right-click+drag bearing change (desktop)
touchPitch: false, // no two-finger pitch on touch devices
renderWorldCopies, // true → infinite worlds; zoom-out capped by the dynamic floor
// NOTE: cooperativeGestures (the two-finger-to-zoom requirement) was REMOVED
// per coder directive 2026-06-24 — one-finger interaction must work normally.
// The single-finger page-scroll escape is now the gray side-edge affordance
// wired below (showEdgeBorders): the .map-scroll-edge gutters flash gray while
// touching the map and are scrollable to move the page past the map.
...(maxBounds ? { maxBounds } : {}), // only if a profile explicitly supplied bounds
attributionControl: false
})
if (isMaplibre) {
// MapLibre branch — free OpenFreeMap basemap, NO access token. Shares the
// GL-JS Map option surface; only the token/style differ from Mapbox.
map.value = new gl.Map({
container: containerRef.value, // real DOM element — never a string ID in shadow DOM
style: maplibreStyle(style),
center,
zoom,
minZoom: initialMinZoom,
maxZoom,
pitch,
bearing,
dragRotate: false,
renderWorldCopies,
...(maxBounds ? { maxBounds } : {}),
attributionControl: false
})
} else {
map.value = new gl.Map({
accessToken: token, // BP #1 from W1A — per-instance token; avoids last-writer-wins on multi-embed pages
container: containerRef.value, // real DOM element — never a string ID in shadow DOM
style,
center,
zoom,
minZoom: initialMinZoom,
maxZoom,
pitch,
bearing,
dragRotate: false, // disorienting north-drift: no right-click+drag bearing change (desktop)
touchPitch: false, // no two-finger pitch on touch devices
renderWorldCopies, // true → infinite worlds; zoom-out capped by the dynamic floor
// NOTE: cooperativeGestures (the two-finger-to-zoom requirement) was REMOVED
// per coder directive 2026-06-24 — one-finger interaction must work normally.
// The single-finger page-scroll escape is now the gray side-edge affordance
// wired below (showEdgeBorders): the .map-scroll-edge gutters flash gray while
// touching the map and are scrollable to move the page past the map.
...(maxBounds ? { maxBounds } : {}), // only if a profile explicitly supplied bounds
attributionControl: false
})
}
// Belt-and-suspenders to dragRotate:false — kills the two-finger twist→rotate
// handler so the north bearing can never drift on any input method.
map.value.touchZoomRotate.disableRotation()
// handler so the north bearing can never drift on any input method. Present on
// both engines.
try { map.value.touchZoomRotate.disableRotation() } catch (_) { /* engine may differ */ }
// ─── Edge scroll-affordance strips (custom page-scroll "scrollbar") ─────────
// The .map-scroll-edge strips (left + right, every profile: pointer-events auto,

View file

@ -0,0 +1,47 @@
/**
* useMapProvider.js — resolve the active map engine ("provider").
*
* Resolution order (first defined wins):
* 1. profile-config `provider` — per-map, set in the profile-config JSON
* 2. window.MAP_PROVIDER — runtime override (e.g. a host reads ?provider=…)
* 3. 'mapbox' — default; existing behaviour, never regresses
*
* Valid values: 'mapbox' | 'maplibre'. Anything else falls back to the default.
*/
import { inject } from 'vue'
export const MAP_PROVIDERS = ['mapbox', 'maplibre']
export const DEFAULT_PROVIDER = 'mapbox'
/** Unwrap a ref/computed to its config object; pass plain objects through. */
function unwrapConfig(profileConfig) {
if (profileConfig && typeof profileConfig === 'object'
&& 'value' in profileConfig && !('provider' in profileConfig) && !('profile' in profileConfig)) {
return profileConfig.value
}
return profileConfig
}
/**
* Resolve the provider from a profile-config (object or ref) + window fallback.
* Pure — safe to call outside a Vue setup().
*/
export function resolveMapProvider(profileConfig) {
const cfg = unwrapConfig(profileConfig)
const fromConfig = cfg?.provider
const fromWindow = typeof window !== 'undefined' ? window.MAP_PROVIDER : undefined
const raw = String(fromConfig || fromWindow || DEFAULT_PROVIDER).toLowerCase().trim()
return MAP_PROVIDERS.includes(raw) ? raw : DEFAULT_PROVIDER
}
/** Human-readable engine label for a badge / UI. */
export function providerLabel(provider) {
return provider === 'maplibre' ? 'MapLibre' : 'Mapbox'
}
/** Convenience for use inside a Vue setup() — injects the provided profileConfig. */
export function useMapProvider() {
const profileConfig = inject('profileConfig', null)
return resolveMapProvider(profileConfig)
}

View file

@ -45,7 +45,9 @@
"vite-plugin-css-injected-by-js": "^3.5.2"
},
"dependencies": {
"@maplibre/maplibre-gl-geocoder": "^1.7.0",
"jspdf": "^4.2.1",
"maplibre-gl": "^5.6.0",
"pinia": "^2.3.1",
"topojson-client": "^3.1.0",
"vue": "^3.5.20",

View file

@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embeddable JAMstack Maps · cities-internet</title>
<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. Embeddable JAMstack Maps design language. */
architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
</head>
<body>
<header class="top">
<h1>Embeddable JAMstack Maps · cities-internet<span class="ac"> / cities-internet</span></h1>
<h1>doxa-maps<span class="ac"> / cities-internet</span></h1>
<div class="sub">cities-internet — one bundle, its profiles as paste-anywhere maps</div>
</header>
<div class="strip" id="frameInfo">loading…</div>

File diff suppressed because one or more lines are too long

View file

@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embeddable JAMstack Maps · country-posters</title>
<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. Embeddable JAMstack Maps design language. */
architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
</head>
<body>
<header class="top">
<h1>Embeddable JAMstack Maps · country-posters<span class="ac"> / country-posters</span></h1>
<h1>doxa-maps<span class="ac"> / country-posters</span></h1>
<div class="sub">country-posters — one bundle, its profiles as paste-anywhere maps</div>
</header>
<div class="strip" id="frameInfo">loading…</div>

File diff suppressed because one or more lines are too long

View file

@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embeddable JAMstack Maps · demo-map</title>
<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. Embeddable JAMstack Maps design language. */
architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
</head>
<body>
<header class="top">
<h1>Embeddable JAMstack Maps · demo-map<span class="ac"> / demo-map</span></h1>
<h1>doxa-maps<span class="ac"> / demo-map</span></h1>
<div class="sub">demo-map — one bundle, its profiles as paste-anywhere maps</div>
</header>
<div class="strip" id="frameInfo">loading…</div>

File diff suppressed because one or more lines are too long

View file

@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embeddable JAMstack Maps · doxa-research-map</title>
<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. Embeddable JAMstack Maps design language. */
architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
</head>
<body>
<header class="top">
<h1>Embeddable JAMstack Maps · doxa-research-map<span class="ac"> / doxa-research-map</span></h1>
<h1>doxa-maps<span class="ac"> / doxa-research-map</span></h1>
<div class="sub">doxa-research-map — one bundle, its profiles as paste-anywhere maps</div>
</header>
<div class="strip" id="frameInfo">loading…</div>

File diff suppressed because one or more lines are too long

View file

@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embeddable JAMstack Maps · doxa-simple-map</title>
<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. Embeddable JAMstack Maps design language. */
architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
</head>
<body>
<header class="top">
<h1>Embeddable JAMstack Maps · doxa-simple-map<span class="ac"> / doxa-simple-map</span></h1>
<h1>doxa-maps<span class="ac"> / doxa-simple-map</span></h1>
<div class="sub">doxa-simple-map — one bundle, its profiles as paste-anywhere maps</div>
</header>
<div class="strip" id="frameInfo">loading…</div>

View file

@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embeddable JAMstack Maps · gospel-resources</title>
<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. Embeddable JAMstack Maps design language. */
architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
</head>
<body>
<header class="top">
<h1>Embeddable JAMstack Maps · gospel-resources<span class="ac"> / gospel-resources</span></h1>
<h1>doxa-maps<span class="ac"> / gospel-resources</span></h1>
<div class="sub">gospel-resources — one bundle, its profiles as paste-anywhere maps</div>
</header>
<div class="strip" id="frameInfo">loading…</div>

View file

@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embeddable JAMstack Maps · staging</title>
<title>doxa-maps · staging</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. Embeddable JAMstack Maps design language. */
architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
</head>
<body>
<header class="top">
<h1>Embeddable JAMstack Maps<span class="ac"></span></h1>
<h1>doxa-maps<span class="ac"></span></h1>
<div class="sub">one shared core → many self-contained, paste-anywhere map bundles</div>
</header>
<div class="strip" id="frameInfo">loading…</div>

View file

@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embeddable JAMstack Maps · key-cities</title>
<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. Embeddable JAMstack Maps design language. */
architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
</head>
<body>
<header class="top">
<h1>Embeddable JAMstack Maps · key-cities<span class="ac"> / key-cities</span></h1>
<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>

View file

@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embeddable JAMstack Maps · my-upg-100-list</title>
<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. Embeddable JAMstack Maps design language. */
architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
</head>
<body>
<header class="top">
<h1>Embeddable JAMstack Maps · my-upg-100-list<span class="ac"> / my-upg-100-list</span></h1>
<h1>doxa-maps<span class="ac"> / my-upg-100-list</span></h1>
<div class="sub">my-upg-100-list — one bundle, its profiles as paste-anywhere maps</div>
</header>
<div class="strip" id="frameInfo">loading…</div>

File diff suppressed because one or more lines are too long

View file

@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embeddable JAMstack Maps · template-bundle</title>
<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. Embeddable JAMstack Maps design language. */
architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
</head>
<body>
<header class="top">
<h1>Embeddable JAMstack Maps · template-bundle<span class="ac"> / template-bundle</span></h1>
<h1>doxa-maps<span class="ac"> / template-bundle</span></h1>
<div class="sub">template-bundle — one bundle, its profiles as paste-anywhere maps</div>
</header>
<div class="strip" id="frameInfo">loading…</div>